From c9ffa5275715407dd308b5ab41428f9c47adada3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 15:36:37 -0400 Subject: [PATCH 01/33] fix(pm): stream verbose uv build-backend logs Enable verbose uv output whenever PM streams a command so long native dependency builds expose package activity and backend stdout/stderr before failure. Verify both sync and requirements installs with real offline uv builds that wait for their output to reach the parent. Both cases fail on the base and pass with this change. --- pm/environment.py | 2 + tests/pm/test_environment_build.py | 53 ++++++++++++++++++++ website/docs/reference/package-management.md | 1 + 3 files changed, 56 insertions(+) diff --git a/pm/environment.py b/pm/environment.py index 55c4eb2293..da58704050 100644 --- a/pm/environment.py +++ b/pm/environment.py @@ -152,6 +152,8 @@ class PythonEnvironment: if self.offline: command.append("--offline") if self.output is not None: + # uv hides build-backend output until failure without verbose mode. + command.append("--verbose") return _run_streaming(command, cwd=cwd, env=env, timeout=timeout, output=self.output) return subprocess.run(command, cwd=str(cwd), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index be1fb283e1..6a4f5c7e2e 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -215,6 +215,59 @@ def test_group_only_build_excludes_application_dependencies(locked_project, tmp_ cwd=tmp_path, env=env) == "1.0" +@pytest.mark.parametrize("operation", ["sync", "requirements"]) +def test_build_backend_output_is_streamed_before_build_finishes(installable_project, tmp_path, operation): + import io + from pm.environment import PythonEnvironment + + source, uv, env = installable_project + release = tmp_path / "release-build" + stdout_marker = "construction-root: backend stdout" + stderr_marker = "construction-root: backend stderr" + backend = source / "local_backend.py" + backend.write_text(backend.read_text() + f''' +import sys +import time + +original_build = build_wheel + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + print({stdout_marker!r}, flush=True) + print({stderr_marker!r}, file=sys.stderr, flush=True) + release = Path({str(release)!r}) + deadline = time.monotonic() + 10 + while not release.exists() and time.monotonic() < deadline: + time.sleep(.01) + assert release.exists(), "backend output was hidden until build exit" + return original_build(wheel_directory, config_settings, metadata_directory) + +build_editable = build_wheel +''', encoding="utf-8") + + class AcknowledgingLog(io.StringIO): + def write(self, text): + written = super().write(text) + if stdout_marker in self.getvalue() and stderr_marker in self.getvalue(): + release.touch() + return written + + output = AcknowledgingLog() + environment = PythonEnvironment( + uv=uv, python=Path(sys.executable), destination=tmp_path / "built", + cache=tmp_path / "build-cache", offline=True, output=output, + env=dict(env, UV_NO_INDEX="1", UV_FIND_LINKS=str(tmp_path / "wheels")), + ) + environment.create() + if operation == "sync": + environment.sync(source, timeout=30) + else: + environment.install_requirements([source.as_uri()]) + environment.check() + assert release.is_file(), "both backend streams must arrive during the build" + assert _run([str(environment.executable), "-I", "-c", "import root_app; print(root_app.VALUE)"], + cwd=tmp_path, env=env) == "installed from the explicit source" + + def test_child_output_is_live_and_keeps_explicit_index_credentials(tmp_path): import io from pm.environment import PythonEnvironment diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 173b3469a7..0117bac1cc 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -459,6 +459,7 @@ remain under uv and npm's own retry policies. ## Diagnostics +- **Slow Python dependency builds:** PM's streamed uv commands enable verbose output. Bundle and build logs show package activity and build-backend stdout/stderr while the build runs, not only after failure. - **Missing or outdated tool:** read `hermes pm doctor`, then use an explicit PM install on a writable installation. - **New environment requires restart:** restart the affected Hermes process. Do not add a second site-packages tree to its live imports. - **Dependency conflict:** read `hermes pm status`. Correct the plugin requirements before retrying admission. From da076f3fa41cb2feab37908100cb4e6be62ba8b8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:16 -0400 Subject: [PATCH 02/33] Unify pinned tool installation and cross-target staging Use one acquisition, assembly, verification, replacement, rollback and cleanup path. Keep host facts and cross-target markers as concrete recording differences, including interrupted-entry recovery and verified Python copies. Exercise both routes with real archive servers, pause/resume, multi-archive progress, post-publication failure and killed publishers. Keep native Windows directory-hold coverage gated to its host. --- pm/ensure.py | 165 +++++++--------------- tests/pm/test_install_download_control.py | 34 +++-- tests/pm/test_pm_authority.py | 130 +++++++++++++---- tests/pm/test_stage_only.py | 34 ++++- tests/pm/test_uv_python.py | 35 +++-- 5 files changed, 229 insertions(+), 169 deletions(-) diff --git a/pm/ensure.py b/pm/ensure.py index b1e2474773..4a1c17516c 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -20,17 +20,8 @@ from pm.store import Store, current_target, merge_tree, tree_digest LOG = logging.getLogger(__name__) -# ``progress(stage, done, total, label)`` — stage is "download" | "unpack", -# label is the archive counter ("1/2") when a package has several. Slow -# lines sit in one stage for minutes, so the byte counters are what prove -# liveness to a UI. - - -def _artifact_progress(progress, index: int, count: int): - if progress is None: - return None - label = f"{index + 1}/{count}" if count > 1 else "" - return lambda done, total: progress("download", done, total, label) +# ``progress(stage, done, total, label)`` reports download/unpack/verify; +# multi-archive labels follow lockfile order. def _lockfile() -> Lockfile: @@ -224,7 +215,7 @@ def _restore_previous_entry(store: Store, entry, previous) -> None: def _install( package: Package, lockfile: Lockfile, - facts: Facts, + facts: Facts | None, store: Store, target: str, progress=None, @@ -232,7 +223,8 @@ def _install( download_progress: ProgressFn | None = None, *, copy_from: tuple[Facts, Store] | None = None, -) -> None: +) -> Path: + """Realize one pin. Host installs commit facts; cross-target stages carry a marker.""" version = lockfile.version(package.name) if version is None: raise InstallError( @@ -243,28 +235,41 @@ def _install( if reason is not None: raise InstallError(package.name, f"unavailable on {target}: {reason}", "none") - artifacts = lockfile.artifacts(package.name, target) entry_name = package.store_entry(version, target) + entry = store.entry(entry_name) + if getattr(package, "pin_only", False): + return entry + artifacts = lockfile.artifacts(package.name, target) + pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]}) with store.install_lock(): if pause_event is not None and pause_event.is_set(): raise DownloadPaused("install paused") - facts.reload() - entry = store.entry(entry_name) - previous_entry = store.entry(f".previous-{entry_name}") + if facts is not None: + facts.reload() + previous = facts.get(package.name) if facts is not None else None + previous_entry = store.entry(f".previous-{'stage-' if facts is None else ''}{entry_name}") if previous_entry.exists(): - # An interrupted replacement keeps its old bytes outside scratch. - # Facts commit last; only a verified committed replacement wins. - fact = facts.get(package.name) - if fact and fact.get("entry") == entry_name and _entry_verified(package, fact, store, target): + # Facts commit last. Stages have no host-side commit record, so + # an interrupted stage always restores its prior usable bytes. + if (previous and previous.get("entry") == entry_name + and _entry_verified(package, previous, store, target)): _remove_entry(store, previous_entry.name) else: _restore_previous_entry(store, entry, previous_entry) - if facts.installed( - package.name, version, store.root, _identity(lockfile, package.name, target) - ) and _entry_verified(package, facts.get(package.name), store, target): + if facts is not None: + current = previous is not None and facts.installed( + package.name, version, store.root, _identity(lockfile, package.name, target) + ) and _entry_verified(package, previous, store, target) + else: + try: + recorded = (entry / ".pm-stage-pin.json").read_text(encoding="utf-8") + except OSError: + recorded = None + current = recorded == pin and not package.verify(entry, target) + if current: _remove_downloads(store, artifacts) - return + return entry if not artifacts: raise InstallError( package.name, @@ -273,7 +278,6 @@ def _install( ) with store.scratch() as scratch: staged = scratch / "tree" - previous = facts.get(package.name) try: def tick(done, total, ranges): if progress is not None: @@ -318,18 +322,21 @@ def _install( reason = package.verify(staged, target) if reason: raise InstallError(package.name, f"staged entry failed verification: {reason}") - if entry.exists(): + if facts is None: + (staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8") + if entry.exists() or entry.is_symlink(): entry.rename(previous_entry) try: store.publish(staged, entry_name) reason = package.verify(entry, target) if reason: raise InstallError(package.name, f"published entry failed verification: {reason}") - facts.record( - package.name, version, entry_name, package.env(entry, target), store.root, - target=target, artifacts=[a["sha256"] for a in artifacts], - digest=tree_digest(entry), - ) + if facts is not None: + facts.record( + package.name, version, entry_name, package.env(entry, target), store.root, + target=target, artifacts=[a["sha256"] for a in artifacts], + digest=tree_digest(entry), + ) except BaseException: if previous_entry.exists(): _restore_previous_entry(store, entry, previous_entry) @@ -353,91 +360,17 @@ def _install( else version ) LOG.info("repair: %s re-realized %s -> %s", package.name, old, new) + return entry -def stage_only(name: str, target: str, progress=None) -> "Path": - """Cross-target staging: publish the pinned (package, version, target) - entry into the store and return its path. No facts are written and no - Runner is composed -- the staged binaries belong to ANOTHER machine - (e.g. linux-arm64-bionic .debs staged on a glibc CI host); this host's - installed-state must not learn about them. Idempotent: an already - published + verifying entry is returned as-is. - """ - lockfile = _lockfile() - store = _store() - package = get_package(name) - version = lockfile.version(package.name) - if version is None: - raise InstallError(package.name, "not in the lockfile") - reason = package.missing_reason(target) - if reason is not None: - raise InstallError(package.name, f"unavailable on {target}: {reason}") - if getattr(package, "pin_only", False): - # A pure pin (e.g. the termux-docker digest): no bytes, no store - # entry, nothing to verify locally -- the pin IS the artifact. - return store.root / package.store_entry(version, target) - artifacts = lockfile.artifacts(package.name, target) - entry_name = package.store_entry(version, target) - # The stage pin marker (same identity shape as a fact's recorded - # artifacts: target + artifact digests) lets stage_only honor a - # same-version hash repin without any host-side facts: the entry - # belongs to ANOTHER machine, so the marker travels inside the entry. - pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]}) - with store.install_lock(): - entry = store.entry(entry_name) - previous_entry = store.entry(f".previous-stage-{entry_name}") - if previous_entry.exists(): - # A killed publisher may have installed only part of the new tree. - _restore_previous_entry(store, entry, previous_entry) - if store.published(entry_name): - marker = entry / ".pm-stage-pin.json" - try: - recorded = marker.read_text(encoding="utf-8") - except OSError: - recorded = None - if not package.verify(entry, target) and recorded == pin: - _remove_downloads(store, artifacts) - return entry - if not artifacts: - raise InstallError( - package.name, - f"no artifact for {target} in the lockfile", - "run `hermes pm lock --bump` for this package", - ) - with store.scratch() as scratch: - staged = scratch / "tree" - for index, artifact in enumerate(artifacts): - archive = store.fetch( - artifact["url"], artifact["sha256"], scratch, - progress=_artifact_progress(progress, index, len(artifacts)), - ) - if index == 0: - package.unpack(archive, staged, target) - else: - extra = scratch / f"extra-{index}" - package.unpack(archive, extra, target) - merge_tree(extra, staged) - package.stage(store, staged, version, target) - reason = package.verify(staged, target) - if reason: - raise InstallError(package.name, f"staged entry failed verification: {reason}") - (staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8") - # Keep the old pin usable until the replacement has been verified. - if entry.exists() or entry.is_symlink(): - entry.rename(previous_entry) - try: - store.publish(staged, entry_name) - reason = package.verify(entry, target) - if reason: - raise InstallError(package.name, f"published entry failed verification: {reason}") - except BaseException: - if previous_entry.exists(): - _restore_previous_entry(store, entry, previous_entry) - raise - if previous_entry.exists(): - _remove_entry(store, previous_entry.name) - _remove_downloads(store, artifacts) - return store.entry(entry_name) +def stage_only( + name: str, target: str, progress=None, *, + pause_event: threading.Event | None = None, + download_progress: ProgressFn | None = None, +) -> Path: + """Realize a cross-target pin without host facts or an executable Runner.""" + return _install(get_package(name), _lockfile(), None, _store(), target, + progress=progress, pause_event=pause_event, download_progress=download_progress) def ensure( @@ -454,7 +387,7 @@ def ensure( policy names, so the policy does not apply to them. ``progress(stage, done, total, label)`` reports the slow parts of an - install to a UI; see _artifact_progress. + install to a UI, including ordered multi-archive labels. """ if isinstance(get_package(name), StatePackage): sync_venv(explicit=explicit) diff --git a/tests/pm/test_install_download_control.py b/tests/pm/test_install_download_control.py index beb7d969ee..d9622cf31d 100644 --- a/tests/pm/test_install_download_control.py +++ b/tests/pm/test_install_download_control.py @@ -4,6 +4,8 @@ from __future__ import annotations import hashlib import io +import json +from functools import partial import threading import zipfile from pathlib import Path @@ -13,7 +15,7 @@ import pytest import pm from pm import paths, registry from pm.downloader import DownloadPaused -from pm.ensure import ensure +from pm.ensure import ensure, stage_only from pm.lock import Facts, Lockfile from pm.package import Package from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @@ -37,7 +39,14 @@ def isolate_home(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) -def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server): +@pytest.fixture(params=["install", "stage"]) +def realize(request): + if request.param == "install": + return partial(ensure, explicit=True) + return partial(stage_only, target="linux-arm64-bionic") + + +def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server, realize): root = tmp_path / "store" lock_path = tmp_path / "lock.json" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) @@ -52,7 +61,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon RangeHandler.payloads[path] = payload pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()}) lock = Lockfile(lock_path) - lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins}) + lock.set_pin(ComponentPackage.name, "1", {"any": pins}) lock.save() pause = threading.Event() @@ -61,7 +70,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon pause.set() with pytest.raises(DownloadPaused): - ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause) + realize(ComponentPackage.name, progress=progress, pause_event=pause) assert Facts(paths.facts_path()).get(ComponentPackage.name) is None assert list(paths.partials_root().glob("*.ranges")) first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] @@ -69,17 +78,22 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon assert (root / f"fetch-{pins[0]['sha256']}").is_dir() pause.clear() - ensure(ComponentPackage.name, explicit=True, pause_event=pause) + result = realize(ComponentPackage.name, pause_event=pause) fact = Facts(paths.facts_path()).get(ComponentPackage.name) - assert fact["artifacts"] == [pin["sha256"] for pin in pins] + if fact is None: + entry = result + assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [pin["sha256"] for pin in pins] + else: + entry = root / fact["entry"] + assert fact["artifacts"] == [pin["sha256"] for pin in pins] for name, body in contents.items(): - assert (root / fact["entry"] / name).read_bytes() == body + assert (entry / name).read_bytes() == body assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests assert not list(paths.partials_root().glob("*.part")) assert not list(root.glob("fetch-*")) -def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server): +def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server, realize): root = tmp_path / "store" lock_path = tmp_path / "lock.json" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) @@ -93,14 +107,14 @@ def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypa RangeHandler.payloads[path] = payload pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()}) lock = Lockfile(lock_path) - lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins}) + lock.set_pin(ComponentPackage.name, "1", {"any": pins}) lock.save() store = pm.Store(root) with store.scratch() as scratch: store.fetch(pins[0]["url"], pins[0]["sha256"], scratch) ticks = [] stages = [] - ensure(ComponentPackage.name, explicit=True, + realize(ComponentPackage.name, progress=lambda *args: stages.append(args), download_progress=lambda done, total, ranges: ticks.append((done, total, ranges))) expected = sum(map(len, payloads)) diff --git a/tests/pm/test_pm_authority.py b/tests/pm/test_pm_authority.py index b5256a4f32..36d824397e 100644 --- a/tests/pm/test_pm_authority.py +++ b/tests/pm/test_pm_authority.py @@ -116,6 +116,42 @@ def _pin(env, name: str, version: str, digest: str) -> None: # ── item 1: digest-bound facts ──────────────────────────────────────── +@pytest.mark.parametrize("route", ["install", "stage"]) +def test_realization_reports_ordered_multi_archive_progress(pm_env, monkeypatch, route): + from pm.ensure import ensure, stage_only + + env = pm_env + monkeypatch.setattr(registry._packages["faketool"], "flatten", False) + artifacts = [] + for name, files in [("tool.tar.gz", {"bin/faketool": "#!tool"}), + ("data.tar.gz", {"share/data": "auxiliary"})]: + _, digest = make_tar(env["docroot"], name, files) + artifacts.append({"url": f"{env['base_url']}/{name}", "sha256": digest}) + lock = Lockfile(env["lockfile_path"]) + lock.set_pin("faketool", "1.0", {"any": artifacts}) + lock.save() + events = [] + progress = lambda stage, done, total, label: events.append((stage, done, total, label)) + if route == "install": + ensure("faketool", explicit=True, base_env={}, progress=progress) + fact = Facts(paths.facts_path()).get("faketool") + entry = paths.store_root() / fact["entry"] + assert fact["artifacts"] == [artifact["sha256"] for artifact in artifacts] + assert fact["digest"] == tree_digest(entry) + assert not (entry / ".pm-stage-pin.json").exists() + else: + entry = stage_only("faketool", "linux-arm64-bionic", progress=progress) + assert json.loads((entry / ".pm-stage-pin.json").read_text()) == { + "target": "linux-arm64-bionic", "sha256": [artifact["sha256"] for artifact in artifacts], + } + assert not paths.facts_path().exists() + assert (entry / "bin/faketool").read_text() == "#!tool" + assert (entry / "share/data").read_text() == "auxiliary" + assert [label for stage, _, _, label in events if stage == "unpack"] == ["1/2", "2/2"] + assert any(stage == "verify" for stage, _, _, _ in events) + assert not list(paths.store_root().glob("fetch-*")) + + def test_same_version_different_sha_is_not_installed_and_repaired(pm_env): """The witness: same version, different artifact sha. Version/path matching cannot see this; identity matching must — check() reports @@ -181,17 +217,27 @@ def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fa assert Facts(facts_path).get("faketool")["digest"] == tree_digest(binary.parent.parent) -@pytest.mark.parametrize("replacement", ["invalid", "publish-failure", "interrupted", "facts-failure"]) -def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, replacement): - from pm.ensure import ensure +@pytest.mark.parametrize(("route", "replacement"), [ + (route, failure) for route in ("install", "stage") + for failure in ("invalid", "publish-failure", "post-publish-invalid", "interrupted", "facts-failure") + if route == "install" or failure != "facts-failure" +]) +def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, route, replacement): + from functools import partial + from pm.ensure import ensure, stage_only from pm.package import InstallError env = pm_env - ensure("faketool", base_env={}) + target = current_target() if route == "install" else "linux-arm64-bionic" + realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial( + stage_only, "faketool", target) + realize() facts_path = paths.facts_path() - old_facts = facts_path.read_bytes() - old = Facts(facts_path).get("faketool") - binary = paths.store_root() / old["entry"] / "bin" / "faketool" + old_facts = facts_path.read_bytes() if facts_path.exists() else None + entry = paths.store_root() / FakeTool().store_entry("1.0", target) + marker = entry / ".pm-stage-pin.json" + old_marker = marker.read_bytes() if marker.exists() else None + binary = entry / "bin/faketool" files = {"bin/unrelated": "bad layout"} if replacement == "invalid" else {"bin/faketool": "#!new"} _, digest = make_tar(env["docroot"], "replacement.tar.gz", files) lockfile = Lockfile(env["lockfile_path"]) @@ -199,15 +245,19 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla "url": f"{env['base_url']}/replacement.tar.gz", "sha256": digest, }}) lockfile.save() - if replacement in ("publish-failure", "interrupted"): + if replacement in ("publish-failure", "post-publish-invalid", "interrupted"): original = Store.publish def fail_package_publish(self, staged, name): - if name == old["entry"]: - if replacement == "interrupted": - raise KeyboardInterrupt() - raise OSError("replacement publication failed") - return original(self, staged, name) + if name != entry.name: + return original(self, staged, name) + if replacement == "interrupted": + raise KeyboardInterrupt() + if replacement == "post-publish-invalid": + published = original(self, staged, name) + (published / "bin/faketool").unlink() + return published + raise OSError("replacement publication failed") monkeypatch.setattr(Store, "publish", fail_package_publish) elif replacement == "facts-failure": @@ -217,24 +267,33 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla expected_error = KeyboardInterrupt if replacement == "interrupted" else InstallError with pytest.raises(expected_error): - ensure("faketool", explicit=True, base_env={}) + realize() assert binary.read_bytes() == b"#!x" - assert facts_path.read_bytes() == old_facts + assert (facts_path.read_bytes() if facts_path.exists() else None) == old_facts + assert (marker.read_bytes() if marker.exists() else None) == old_marker -def test_killed_replacement_recovers_on_next_install(pm_env): - """A process exit between moving old bytes and publishing new bytes is recoverable.""" +@pytest.mark.parametrize("route", ["install", "stage"]) +@pytest.mark.parametrize("interruption", ["before-publish", "after-publish"]) +def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption): + """A killed publisher retains old bytes outside scratch until recovery.""" import os import subprocess import sys import textwrap + from functools import partial - from pm.ensure import ensure + from pm.ensure import ensure, stage_only env = pm_env - ensure("faketool", base_env={}) + target = current_target() if route == "install" else "linux-arm64-bionic" + realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial( + stage_only, "faketool", target) + realize() old_fact = Facts(paths.facts_path()).get("faketool") + entry = paths.store_root() / FakeTool().store_entry("1.0", target) + old_digest = tree_digest(entry) _, digest = make_tar(env["docroot"], "replacement.tar.gz", {"bin/faketool": "#!new"}) lockfile = Lockfile(env["lockfile_path"]) lockfile.set_pin("faketool", "1.0", {"any": { @@ -248,28 +307,39 @@ def test_killed_replacement_recovers_on_next_install(pm_env): import pm.registry as registry from pm.store import Store from tests.pm.test_pm_authority import FakeTool - from pm.ensure import ensure + from pm.ensure import ensure, stage_only paths.lockfile_path = lambda: Path(sys.argv[1]) registry._packages[FakeTool.name] = FakeTool() publish = Store.publish def crash(self, staged, name): - if name.startswith('faketool-'): - os._exit(17) - return publish(self, staged, name) + if not name.startswith('faketool-'): + return publish(self, staged, name) + if sys.argv[3] == 'after-publish': + publish(self, staged, name) + os._exit(17) Store.publish = crash - ensure('faketool', explicit=True, base_env={}) + if sys.argv[2] == 'install': + ensure('faketool', explicit=True, base_env={}) + else: + stage_only('faketool', 'linux-arm64-bionic') """) child = subprocess.run( - [sys.executable, "-c", code, str(env["lockfile_path"])], + [sys.executable, "-c", code, str(env["lockfile_path"]), route, interruption], env=dict(os.environ), capture_output=True, text=True, timeout=30, ) assert child.returncode == 17, child.stderr assert Facts(paths.facts_path()).get("faketool") == old_fact - ensure("faketool", explicit=True, base_env={}) - fact = Facts(paths.facts_path()).get("faketool") - entry = paths.store_root() / fact["entry"] - assert (entry / "bin" / "faketool").read_bytes() == b"#!new" - assert fact["digest"] == tree_digest(entry) + previous = entry.with_name(f".previous-{'stage-' if route == 'stage' else ''}{entry.name}") + assert tree_digest(previous) == old_digest + realize() + assert (entry / "bin/faketool").read_bytes() == b"#!new" + assert not previous.exists() + if route == "install": + fact = Facts(paths.facts_path()).get("faketool") + assert fact["digest"] == tree_digest(entry) + else: + assert not paths.facts_path().exists() + assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [digest] def test_failed_restore_preserves_both_interrupted_versions(pm_env, monkeypatch): diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index 377b779c70..e8145bfc1d 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -172,6 +172,38 @@ def test_stage_only_repin_same_version_rebuilds(tmp_path, sandbox, monkeypatch): assert (stable / "bin" / "tool").read_bytes() == payload_b +@pytest.mark.platforms("windows") +def test_stage_repin_refuses_a_native_directory_hold_then_recovers(sandbox, monkeypatch): + import ctypes + from ctypes import wintypes + + original, replacement = b"original", b"replacement" + _arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(original)}]) + _seed_fetch_cache(sandbox, original) + entry = ensure_mod.stage_only("stage-test", TARGET) + marker = (entry / ".pm-stage-pin.json").read_bytes() + _arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(replacement)}]) + _seed_fetch_cache(sandbox, replacement) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] + kernel.CreateFileW.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + # FILE_FLAG_BACKUP_SEMANTICS opens a directory; omit FILE_SHARE_DELETE. + handle = kernel.CreateFileW(str(entry), 0, 3, None, 3, 0x02000000, None) + assert handle != wintypes.HANDLE(-1).value, ctypes.get_last_error() + try: + with pytest.raises(InstallError): + ensure_mod.stage_only("stage-test", TARGET) + assert (entry / "bin/tool").read_bytes() == original + assert (entry / ".pm-stage-pin.json").read_bytes() == marker + finally: + kernel.CloseHandle(handle) + ensure_mod.stage_only("stage-test", TARGET) + assert (entry / "bin/tool").read_bytes() == replacement + + @pytest.mark.parametrize("error_name", ["HashError", "DownloadPaused"]) def test_permanent_or_paused_download_is_not_retried(error_name): from pm import downloader @@ -200,7 +232,7 @@ def test_repin_failure_preserves_previous_staged_entry(sandbox, monkeypatch, fai raise InstallError("stage-test", "injected staging failure") if failure == "fetch": - monkeypatch.setattr(sandbox, "fetch", fail) + monkeypatch.setattr(sandbox, "fetch_many", fail) else: monkeypatch.setattr(sandbox, "publish", fail) with pytest.raises(InstallError, match="injected staging failure"): diff --git a/tests/pm/test_uv_python.py b/tests/pm/test_uv_python.py index ce616a7a5d..e621c66765 100644 --- a/tests/pm/test_uv_python.py +++ b/tests/pm/test_uv_python.py @@ -199,8 +199,7 @@ def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(ins assert tree_digest(entry) == shipped_digest -@pytest.mark.platforms("windows") -@pytest.mark.parametrize("damage", ["source", "copy", "publication"]) +@pytest.mark.parametrize("damage", [None, "source", "copy", "publication"]) def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damage): import pm.registry as registry from pm.store import Store, tree_digest @@ -216,12 +215,15 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag entry_name = python.store_entry("test", target) source = shipped / entry_name source.mkdir() - (source / "python.exe").write_bytes(b"new interpreter") + binary_rel = python.binary(source, target).relative_to(source) + (source / binary_rel).parent.mkdir(parents=True, exist_ok=True) + (source / binary_rel).write_bytes(b"new interpreter") facts.record("python", "test", entry_name, {}, shipped, target=target, artifacts=[digest], digest=tree_digest(source)) previous = writable / entry_name previous.mkdir() - (previous / "python.exe").write_bytes(b"previous interpreter") + (previous / binary_rel).parent.mkdir(parents=True, exist_ok=True) + (previous / binary_rel).write_bytes(b"previous interpreter") previous_facts = Facts(writable / "facts.json") previous_facts.record("python", "previous", entry_name, {}, writable, target=target, artifacts=[digest], digest=tree_digest(previous)) @@ -229,19 +231,28 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag copytree = shutil.copytree if damage == "source": - (source / "python.exe").write_bytes(b"damaged source") + (source / binary_rel).write_bytes(b"damaged source") elif damage == "copy": - def damaged_copy(src, dest, **kwargs): - copytree(src, dest, **kwargs) - (dest / "python.exe").write_bytes(b"damaged copy") + def damaged_copy(src, dest, *args, **kwargs): + result = copytree(src, dest, *args, **kwargs) + if Path(src) == source: + (dest / binary_rel).write_bytes(b"damaged copy") + return result monkeypatch.setattr(shutil, "copytree", damaged_copy) - else: + elif damage == "publication": def failed_publication(*args, **kwargs): raise OSError("publication refused") monkeypatch.setattr(Store, "publish", failed_publication) - with pytest.raises(InstallError, match="verification|copied bytes|publication refused"): + if damage is None: + monkeypatch.setattr(Store, "fetch_many", lambda *args, **kwargs: pytest.fail("copy downloaded bytes")) ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target, copy_from=(facts, Store(shipped))) - assert (previous / "python.exe").read_bytes() == b"previous interpreter" - assert previous_facts.path.read_bytes() == before + assert tree_digest(previous) == tree_digest(source) + assert previous_facts.get("python")["digest"] == facts.get("python")["digest"] + else: + with pytest.raises(InstallError, match="verification|copied bytes|publication refused"): + ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target, + copy_from=(facts, Store(shipped))) + assert (previous / binary_rel).read_bytes() == b"previous interpreter" + assert previous_facts.path.read_bytes() == before From 7f82a8697866f79d59fbcb8dfd93b0b18164f9e0 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:22 -0400 Subject: [PATCH 03/33] Make dependency workspace preparation fresh-generation only Require the caller-selected source, output, seed and prepared environment. Delete reusable-root defaults, changed detection, seed precedence, replacement cleanup and fallback engine creation. Preserve frozen recorded-workspace replay; refuse existing outputs and missing explicit seeds. Migrate lifetime tests onto fresh snapshots and real uv builds. Remove obsolete managed-uv default-lifetime tests, retaining explicit-engine routing coverage. Combined focused acceptance: 221 passed, 2 skipped; known stale node-sidecar and bionic PATH assertions excluded, along with unavailable Python 3.11 bootstrap. --- pm/packages.py | 2 +- pm/workspace.py | 139 +++----------- tests/pm/test_environment_build.py | 4 +- tests/pm/test_recovery_validation.py | 4 +- tests/pm/test_union_installs_members.py | 25 ++- tests/pm/test_workspace.py | 203 +++++++-------------- tests/pm/test_workspace_build_inputs.py | 30 ++- tests/pm/test_workspace_output_encoding.py | 8 +- tests/test_managed_runtime_resolution.py | 35 ---- 9 files changed, 148 insertions(+), 302 deletions(-) delete mode 100644 tests/test_managed_runtime_resolution.py diff --git a/pm/packages.py b/pm/packages.py index a9491e77b4..247308f323 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -429,7 +429,7 @@ class Venv(StatePackage): replay = recorded.parent seed = (Path(prior["resolved_lock"]) if members and prior.get("resolved_lock") else project / "uv.lock") - lock_and_sync(members, extras, venv_dir=candidate, root=generation / "workspace", + 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" diff --git a/pm/workspace.py b/pm/workspace.py index db37ff6938..5d73d143b4 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -19,7 +19,6 @@ if TYPE_CHECKING: from pm import paths from pm.package import InstallError -WORKSPACE_DIRNAME = ".pm-workspace" _MEMBER_EXCLUDE = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__"}) @@ -58,17 +57,6 @@ def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallErro return InstallError("venv", cause) -def workspace_root() -> Path: - """Default preparation root; callers can supply a fresh transaction root.""" - from hermes_cli.runtime_paths import install_state_dir - return install_state_dir(paths.repo_root()) / WORKSPACE_DIRNAME - - -def _member_rel(root: Path, plugin_dir: Path) -> str: - """Use portable separators for a member inside the generated workspace.""" - return os.path.relpath(plugin_dir.resolve(), root.resolve()).replace("\\", "/") - - def member_sources(plugin_dirs) -> dict[Path, Path]: """Map installed identities to build inputs, including staged plugin updates.""" rows = plugin_dirs.items() if isinstance(plugin_dirs, Mapping) else ((path, path) for path in plugin_dirs) @@ -100,8 +88,6 @@ def members_stamp(plugin_dirs) -> str: def _copy_core_inputs(source: Path, destination: Path) -> None: """Build from a writable snapshot, never from signed/read-only source.""" - import shutil - import fnmatch import tomllib @@ -130,8 +116,6 @@ def _copy_core_inputs(source: Path, destination: Path) -> None: and not entry.name.startswith(".") and entry.resolve() != destination.resolve() and any(fnmatch.fnmatchcase(entry.name, pattern) for pattern in package_roots)): target = destination / entry.name - if target.exists(): - shutil.rmtree(target) shutil.copytree(entry, target, ignore=ignore) for name in files: entry = source / name @@ -144,24 +128,17 @@ def _copy_core_inputs(source: Path, destination: Path) -> None: shutil.copy2(entry, target) -def _generate_pyproject(plugin_dirs: list[Path], root: Optional[Path] = None, *, - source: Optional[Path] = None) -> tuple[Path, bool]: - """(Re)generate the workspace root's pyproject.toml from core's - pyproject + the enabled plugin members. Idempotent — same inputs, - same bytes. Returns (root, changed): changed is True when the member - surface moved (member set or a member's pyproject content), which is - the signal to re-seed the resolution from the committed lock.""" - if root is None: - root = workspace_root() - source = (paths.repo_root() if source is None else source).resolve() +def _generate_pyproject(plugin_dirs: list[Path] | Mapping[Path, Path], root: Path, *, source: Path) -> None: + """Snapshot core and plugin build inputs into a fresh generation.""" + source = source.resolve() if root.resolve() == source or source.is_relative_to(root.resolve()): raise InstallError("venv", "workspace must not replace the core source") - root.mkdir(parents=True, exist_ok=True) + root.mkdir(parents=True) core_pyproject = source / "pyproject.toml" core_text = core_pyproject.read_text(encoding="utf-8-sig") - members = [_member_rel(root, _workspace_member(source, root, identity=identity)) + members = [_workspace_member(source, root, identity=identity).relative_to(root).as_posix() for identity, source in member_sources(plugin_dirs).items()] lines = [core_text.rstrip("\n")] @@ -172,40 +149,8 @@ def _generate_pyproject(plugin_dirs: list[Path], root: Optional[Path] = None, *, text = "\n".join(lines) + "\n" target = root / "pyproject.toml" - try: - changed = target.read_text(encoding="utf-8") != text - except OSError: - changed = True _copy_core_inputs(source, root) target.write_text(text, encoding="utf-8") - return root, changed - - -def _seed_lock(root: Path, seed_lock: Optional[Path] = None, *, source: Optional[Path] = None) -> None: - """Seed the generated root's uv.lock with the CURRENT resolution. - - Seed precedence: the parent-supplied ``seed_lock`` path first, then - the root's own existing uv.lock (the current EXTENDED resolution from - the previous sync), then the committed core lock — so a plugin-driven - extension keeps every compatible selection it already made, and a - fresh root extends the committed resolution. uv preserves compatible - selections from the seed (a plugin's range spec does not move core - pins); explicit exact requirements stay binding as declared - constraints. The lock is COPIED — shipped/extended source bytes are - never rewritten; only the staging root receives the copy. - - Called only when the member surface changed; an unchanged root keeps - its lock untouched, so repeated syncs are stable. Seed failures - SURFACE (they would silently degrade the resolution otherwise).""" - if seed_lock is None: - existing = root / "uv.lock" - if existing.is_file(): - seed_lock = existing - else: - seed_lock = (paths.repo_root() if source is None else source) / "uv.lock" - if not seed_lock.is_file(): - return # nothing committed to seed from; uv resolves from scratch - (root / "uv.lock").write_bytes(seed_lock.read_bytes()) def _is_member_candidate(plugin_dir: Path) -> bool: @@ -283,18 +228,15 @@ def _legacy_requirements(plugin_dir: Path) -> list[str]: return list(dict.fromkeys(specs)) -def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = None) -> Path: +def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path: """Keep workspace members with their generation, not a temporary install clone.""" import json - import shutil import tomllib - key = hashlib.sha256(str((identity or plugin_dir).resolve()).encode()).hexdigest()[:16] + key = hashlib.sha256(str(identity.resolve()).encode()).hexdigest()[:16] pyproject = plugin_dir / "pyproject.toml" if pyproject.is_file() and "GENERATED by pm" not in pyproject.read_text(encoding="utf-8-sig"): member = root / "plugin-sources" / key - if member.exists(): - shutil.rmtree(member) shutil.copytree(plugin_dir, member, symlinks=True, ignore=_member_ignored) document = tomllib.loads(pyproject.read_text(encoding="utf-8-sig")) @@ -309,7 +251,7 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = N resolved = (plugin_dir / relative).resolve() if resolved.is_relative_to(plugin_dir.resolve()): continue # The referenced tree was copied with this member. - spec["path"] = ((identity or plugin_dir) / relative).resolve().as_posix() + spec["path"] = (identity / relative).resolve().as_posix() changed = True if changed: import tomli_w @@ -318,7 +260,7 @@ def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = N return member specs = _legacy_requirements(plugin_dir) member = root / "plugin-deps" / key - member.mkdir(parents=True, exist_ok=True) + member.mkdir(parents=True) (member / "pyproject.toml").write_text( f'[project]\nname = "hermes-plugin-{key}"\nversion = "0.0.0"\n' 'requires-python = ">=3.11"\n' @@ -376,57 +318,34 @@ def install_node_sidecar( def lock_and_sync( - plugin_dirs: list[Path], - extras: Optional[list[str]] = None, + plugin_dirs: list[Path] | Mapping[Path, Path], + extras: list[str], *, - venv_dir: Path, - root: Optional[Path] = None, - env: Optional[dict] = None, - seed_lock: Optional[Path] = None, + root: Path, + source: Path, + seed_lock: Path | None, + environment: PythonEnvironment, frozen: bool = False, - replay: Optional[Path] = None, - source: Optional[Path] = None, - environment: PythonEnvironment | None = None, + replay: Path | None = None, ) -> None: - """Build the root, then `uv lock` + `uv sync --frozen --extra ...`. + """Prepare a fresh generation using explicit inputs and a prepared engine. - Everything resolves into a parent-supplied STAGING surface: ``root`` - pins the generated workspace dir, ``venv_dir`` pins - UV_PROJECT_ENVIRONMENT and ``seed_lock`` (optional) pins which - existing lock seeds the extension (default: the root's current - extended lock, else the committed core lock). ``env`` replaces the - ambient base environment when supplied; either way the subprocess - gets a COPY — the live process environment is never mutated. ``replay`` - copies a recorded sibling workspace and uses its lock without resolution - or plugin discovery; it is reserved for restoring an existing selection. - ``source`` and ``environment`` bypass live source/tool discovery when supplied; - the environment owns the child process policy, cache and interpreter. - - Raises a CLASSIFIED InstallError on failure: ResolutionConflict only - for a confirmed resolver conflict; network, build and tool failures - stay generic InstallError — they are not evidence of a dependency - conflict and must not disable plugins. + The caller selects the seed; uv retains its compatible versions. Repair + copies the recorded workspace verbatim and never reads current manifests. + Resolver conflicts remain distinct from download/build failures. """ - if environment is not None and environment.destination != venv_dir: - raise ValueError("workspace and environment destinations differ") - + if root.exists() or root.is_symlink(): + raise InstallError("venv", f"workspace must be fresh: {root}") if replay is None: - generated, changed = _generate_pyproject(plugin_dirs, root, source=source) - if changed: - _seed_lock(generated, seed_lock, source=source) + _generate_pyproject(plugin_dirs, root, source=source) + if seed_lock is not None: + (root / "uv.lock").write_bytes(seed_lock.read_bytes()) else: - import shutil - - if root is None or not (replay / "pyproject.toml").is_file() or not (replay / "uv.lock").is_file(): + if not (replay / "pyproject.toml").is_file() or not (replay / "uv.lock").is_file(): raise InstallError("venv", f"recorded workspace is missing: {replay}") - # Generation workspaces are siblings at the same depth. External - # member paths still resolve. Generated members move with the copy. + # Sibling generations keep external relative paths at the same depth; + # snapshotted members and their exact lock travel with the workspace. shutil.copytree(replay, root, ignore=shutil.ignore_patterns("__pycache__", ".venv", "build", "*.egg-info")) - generated = root frozen = True - if environment is None: - from pm.environment import managed_environment - - environment = managed_environment(venv_dir, env=env) - environment.sync(generated, extras=extras or (), frozen=frozen) + environment.sync(root, extras=extras, frozen=frozen) diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index 6a4f5c7e2e..db9a0dafa6 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -504,7 +504,7 @@ def test_explicit_workspace_preserves_seed_and_replays_copied_members(locked_pro first.create() workspace.lock_and_sync( [original_member], ["chosen"], source=source, root=tmp_path / "first" / "workspace", - venv_dir=first.destination, environment=first, seed_lock=source / "uv.lock", + environment=first, seed_lock=source / "uv.lock", ) first.check() assert _run([str(first.executable), "-I", "-c", "import base_dep, member_dep; print(base_dep.__version__)"], @@ -522,7 +522,7 @@ def test_explicit_workspace_preserves_seed_and_replays_copied_members(locked_pro second.create() workspace.lock_and_sync( [], ["chosen"], source=source, root=tmp_path / "second" / "workspace", - venv_dir=second.destination, environment=second, replay=recorded, + environment=second, seed_lock=None, replay=recorded, ) second.check() assert _run([str(second.executable), "-I", "-c", "import member_dep, chosen_dep; print(member_dep.__version__)"], diff --git a/tests/pm/test_recovery_validation.py b/tests/pm/test_recovery_validation.py index f7b7c14619..cf754a0b7f 100644 --- a/tests/pm/test_recovery_validation.py +++ b/tests/pm/test_recovery_validation.py @@ -42,8 +42,10 @@ def test_validation_rejects_a_missing_required_import(tmp_path, monkeypatch, dam candidate = tmp_path / "venv" monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) from pm.workspace import lock_and_sync + from pm.environment import managed_environment - lock_and_sync([], [], root=workspace, venv_dir=candidate) + lock_and_sync([], [], root=workspace, source=core, seed_lock=None, + environment=managed_environment(candidate)) python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") validate_environment(python, env=env, cwd=workspace) shutil.rmtree(site_packages(candidate) / "dotenv") diff --git a/tests/pm/test_union_installs_members.py b/tests/pm/test_union_installs_members.py index de1b017c9c..bbb0fbf97c 100644 --- a/tests/pm/test_union_installs_members.py +++ b/tests/pm/test_union_installs_members.py @@ -11,12 +11,14 @@ lock, empty site-packages) can never silently return. from __future__ import annotations +import json import sys from pathlib import Path import pytest import pm.workspace as ws +from pm.environment import managed_environment @pytest.fixture(autouse=True) def isolated_machine_home(tmp_path, monkeypatch): @@ -44,12 +46,17 @@ def mini_workspace(tmp_path, monkeypatch): whose dep (pyfiglet) is NOT a root dep. Store paths pointed at tmp.""" core = tmp_path / "core" core.mkdir() + from tests.pm.test_workspace_build_inputs import _wheel + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "pyfiglet", "1.0.2") (core / "pyproject.toml").write_text( "[project]\n" 'name = "fake-core"\n' 'version = "0.1.0"\n' 'requires-python = ">=3.11"\n' - 'dependencies = []\n', + 'dependencies = []\n[tool.uv]\npackage=false\nno-index=true\n' + f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8", ) plug = tmp_path / "plugins" / "member-plug" @@ -74,7 +81,7 @@ def mini_workspace(tmp_path, monkeypatch): monkeypatch.setattr(pm.paths, "store_root", lambda: store) monkeypatch.setattr(ws.paths, "repo_root", lambda: core) monkeypatch.setattr(ws.paths, "store_root", lambda: store) - return tmp_path, plug, venv + return tmp_path, core, plug, venv @pytest.mark.skipif(_uv_available() is False, reason="uv not on PATH") @@ -82,8 +89,9 @@ def test_lock_and_sync_installs_member_deps(mini_workspace): """The probe scenario: after lock_and_sync, the member's dep MUST be importable from the synced venv. Under plain `uv sync --frozen` the lock contains the dep but site-packages does not — this fails.""" - _, plug, venv = mini_workspace - ws.lock_and_sync([plug], [], venv_dir=venv) + tmp, core, plug, venv = mini_workspace + ws.lock_and_sync([plug], [], root=tmp / "workspace", source=core, seed_lock=None, + environment=managed_environment(venv)) sp = _site_packages(venv) assert sp.is_dir(), "no site-packages in the synced venv" @@ -102,9 +110,12 @@ def test_union_survives_a_resync(mini_workspace): """The prune-proof contract: a second lock_and_sync (what an update rebuild does) must NOT remove the member's deps — they are in the union lock, not pip-guests.""" - _, plug, venv = mini_workspace - ws.lock_and_sync([plug], [], venv_dir=venv) - ws.lock_and_sync([plug], [], venv_dir=venv) + tmp, core, plug, venv = mini_workspace + ws.lock_and_sync([plug], [], root=tmp / "workspace", source=core, seed_lock=None, + environment=managed_environment(venv)) + venv = tmp / "next-venv" + ws.lock_and_sync([plug], [], root=tmp / "next-workspace", source=core, + seed_lock=tmp / "workspace" / "uv.lock", environment=managed_environment(venv)) sp = _site_packages(venv) assert any(p.name.startswith("pyfiglet") for p in sp.iterdir()), ( diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 5db887213d..527ef87b1a 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -3,9 +3,8 @@ The workspace root is a pm-GENERATED project (never the committed pyproject.toml — sealed installs are read-only and member lists are machine-specific). Its pyproject = core's pyproject verbatim + -``[tool.uv.workspace] members`` pointing at each enabled plugin dir via -relative ``../``-escaping paths (proven to resolve). ``uv lock`` unions -core + plugin deps into ONE lock; conflict = loud refusal. +``[tool.uv.workspace] members`` pointing at each snapshotted plugin. +``uv lock`` unions core + plugin deps into ONE lock; conflict = loud refusal. """ from __future__ import annotations @@ -20,6 +19,7 @@ from pathlib import Path import pytest import pm.workspace as ws +from pm.environment import managed_environment @pytest.fixture(autouse=True) @@ -68,17 +68,11 @@ def layout(tmp_path, monkeypatch): return tmp_path, core, plug_a, store -def test_workspace_root_is_per_install_not_in_the_store(layout): - from hermes_cli.runtime_paths import install_state_dir - _, core, _, store = layout - assert ws.workspace_root() == install_state_dir(core) / ".pm-workspace" - assert not ws.workspace_root().is_relative_to(store) - - def test_build_writes_core_pyproject_verbatim(layout): - _, core, plug_a, _ = layout - root = ws.workspace_root() - ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") + tmp, core, plug_a, _ = layout + root = tmp / "workspace" + ws.lock_and_sync([plug_a], [], root=root, source=core, seed_lock=None, + environment=managed_environment(tmp / "env")) text = (root / "pyproject.toml").read_text(encoding="utf-8") core_text = (core / "pyproject.toml").read_text(encoding="utf-8") # core's project table is carried verbatim (name, deps, requires-python) @@ -93,9 +87,10 @@ def test_build_writes_core_pyproject_verbatim(layout): def test_members_keep_their_source_with_the_generation(layout): import tomllib - _, _, plug_a, _ = layout - root = ws.workspace_root() - ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") + tmp, core, plug_a, _ = layout + root = tmp / "workspace" + ws.lock_and_sync([plug_a], [], root=root, source=core, seed_lock=None, + environment=managed_environment(tmp / "env")) document = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) [relative] = document["tool"]["uv"]["workspace"]["members"] copied = root / relative / "pyproject.toml" @@ -106,20 +101,35 @@ def test_members_keep_their_source_with_the_generation(layout): assert copied.read_bytes() == before -def test_build_is_idempotent(layout): - _, _, plug_a, _ = layout - root = ws.workspace_root() - ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") - first = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8") - ws.lock_and_sync([plug_a], root=root, venv_dir=root.parent / "env") - second = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8") - assert first == second +def test_preparation_refuses_existing_workspace_without_mutating_it(layout): + from pm.package import InstallError + + tmp, core, plug_a, _ = layout + root = tmp / "workspace" + environment = managed_environment(tmp / "env") + kwargs = dict(root=root, source=core, seed_lock=None, + environment=environment) + ws.lock_and_sync([plug_a], [], **kwargs) + before = {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} + (plug_a / "pyproject.toml").write_text('changed after publication') + with pytest.raises(InstallError, match="fresh"): + ws.lock_and_sync([], [], **kwargs) + assert {p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file()} == before + + +def test_missing_explicit_seed_cannot_silently_resolve_new_versions(layout): + tmp, core, plug_a, _ = layout + with pytest.raises(FileNotFoundError): + ws.lock_and_sync([plug_a], [], root=tmp / "workspace", source=core, + seed_lock=tmp / "missing.lock", environment=managed_environment(tmp / "env")) + assert not (tmp / "env").exists() def test_zero_plugins_still_builds_a_root_with_no_members(layout): - _, _, _, _ = layout - root = ws.workspace_root() - ws.lock_and_sync([], root=root, venv_dir=root.parent / "env") + tmp, core, _, _ = layout + root = tmp / "workspace" + ws.lock_and_sync([], [], root=root, source=core, seed_lock=None, + environment=managed_environment(tmp / "env")) text = (root / "pyproject.toml").read_text(encoding="utf-8") assert 'name = "hermes-agent"' in text assert "[tool.uv.workspace]" not in text or "members = []" in text @@ -261,115 +271,40 @@ def test_classify_network_failure_stays_generic(): assert not isinstance(err, ResolutionConflict) -def test_sync_failure_is_never_a_conflict(tmp_path, monkeypatch): - """--frozen: the lock already resolved, so a sync failure (download, - build, tooling) must stay generic — it must not disable plugins.""" +def test_sync_failure_is_never_a_conflict(layout, monkeypatch): from pm.package import InstallError - from pm.workspace import ResolutionConflict - class FakeProc: - returncode = 1 - stderr = "error: Failed to download wheel (connection reset)" - stdout = "" - - monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (Path("/x/ws"), False)) - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python"))) - - captured = {} - - def fake_run(cmd, **kwargs): - captured["cmd"] = cmd - return FakeProc() - - monkeypatch.setattr(subprocess, "run", fake_run) + tmp, core, _, _ = layout + environment = managed_environment(tmp / "candidate") + monkeypatch.setattr(subprocess, "run", lambda cmd, **kwargs: + subprocess.CompletedProcess(cmd, 1, "", "Failed to download wheel")) with pytest.raises(InstallError) as excinfo: - ws.lock_and_sync([], [], venv_dir=tmp_path / "candidate") - assert not isinstance(excinfo.value, ResolutionConflict) + ws.lock_and_sync([], [], root=tmp / "workspace", source=core, + seed_lock=None, environment=environment, frozen=True) + assert not isinstance(excinfo.value, ws.ResolutionConflict) -def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp_path): - """lock_and_sync must resolve into the PARENT-SUPPLIED staging root + - venv, pass a COPY of the environment (never mutate the live one), and - leave the default generated root untouched.""" - staging = tmp_path / "staging-ws" - staging.mkdir() - - seen = {} - - class FakeProc: - returncode = 0 - stderr = "" - stdout = "" - - def fake_run(cmd, cwd=None, env=None, **kwargs): - seen["cwd"] = cwd - seen["env"] = env - return FakeProc() - - monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (staging, False)) - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python"))) - monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "cache") - monkeypatch.setattr(subprocess, "run", fake_run) - - live_key = "PM_WORKSPACE_TEST_SENTINEL" - os.environ[live_key] = "live" - try: - ws.lock_and_sync( - [], [], venv_dir=tmp_path / "staging-venv", root=staging, - env={"PATH": "/staged/bin", live_key: "staged"}, - ) - - assert Path(seen["cwd"]) == staging - assert seen["env"][live_key] == "staged" # staged env wins - assert seen["env"]["UV_CACHE_DIR"] == str(tmp_path / "cache") - assert seen["env"]["UV_PROJECT_ENVIRONMENT"] == str(tmp_path / "staging-venv") - assert seen["env"]["UV_PYTHON"] == "pm-python" - assert os.environ[live_key] == "live" # live env untouched - finally: - del os.environ[live_key] - - -def test_changed_root_seeds_from_committed_lock_unchanged_keeps_extended( - layout, monkeypatch, tmp_path -): - """Seed the CURRENT resolution without writing shipped bytes: a fresh - root seeds from the committed lock; an existing root seeds from its - own extended lock; an explicit parent-supplied seed_lock wins.""" - _, core, plug_a, store = layout - (core / "uv.lock").write_bytes(b"committed-bytes\n") - - class FakeProc: - returncode = 0 - stderr = "" - stdout = "" - - monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path("uv"), Path("pm-python"))) - monkeypatch.setattr(subprocess, "run", lambda cmd, **k: FakeProc()) - - root = ws.workspace_root() - venv = tmp_path / "venv" - - # First sync: fresh root -> seeded from the COMMITTED lock. - ws.lock_and_sync([plug_a], [], venv_dir=venv) - assert (root / "uv.lock").read_bytes() == b"committed-bytes\n" - - # Changed surface (new member), root's own EXTENDED lock present -> - # the current extended resolution is the seed, not the committed one. - (root / "uv.lock").write_bytes(b"extended-bytes\n") - plug_b = layout[0] / "home" / "plugins" / "plug-b" - plug_b.mkdir(parents=True) - (plug_b / "pyproject.toml").write_text("[project]\n", encoding="utf-8") - ws.lock_and_sync([plug_a, plug_b], [], venv_dir=venv) - assert (root / "uv.lock").read_bytes() == b"extended-bytes\n" - - # Parent-supplied seed_lock wins over both. - other = tmp_path / "parent-extended.lock" - other.write_bytes(b"parent-bytes\n") - plug_c = layout[0] / "home" / "plugins" / "plug-c" - plug_c.mkdir(parents=True) - (plug_c / "pyproject.toml").write_text("[project]\n", encoding="utf-8") - ws.lock_and_sync([plug_a, plug_b, plug_c], [], venv_dir=venv, seed_lock=other) - assert (root / "uv.lock").read_bytes() == b"parent-bytes\n" - - # Shipped core lock untouched throughout. - assert (core / "uv.lock").read_bytes() == b"committed-bytes\n" +def test_staging_root_and_env_are_honored_without_live_mutation(layout, monkeypatch): + tmp, core, _, _ = layout + staging = tmp / "staging-ws" + monkeypatch.setenv("PM_WORKSPACE_TEST_SENTINEL", "live") + environment = managed_environment(tmp / "staging-venv", env={ + "PATH": "/staged/bin", "PM_WORKSPACE_TEST_SENTINEL": "staged", + }) + # The prepared environment is authoritative; workspace never discovers tools. + monkeypatch.setattr(shutil, "which", lambda *args, **kwargs: pytest.fail("PATH discovery")) + seen = [] + def run(cmd, **kwargs): + seen.append((cmd, kwargs)) + return subprocess.CompletedProcess(cmd, 0, "", "") + monkeypatch.setattr(subprocess, "run", run) + ws.lock_and_sync([], [], root=staging, source=core, seed_lock=None, environment=environment) + assert [cmd[1] for cmd, _ in seen] == ["lock", "sync"] + for cmd, kwargs in seen: + assert Path(cmd[0]) == environment.uv + assert Path(kwargs["cwd"]) == staging + assert kwargs["env"]["PM_WORKSPACE_TEST_SENTINEL"] == "staged" + assert kwargs["env"]["UV_CACHE_DIR"] == str(environment.cache) + assert kwargs["env"]["UV_PROJECT_ENVIRONMENT"] == str(environment.destination) + assert kwargs["env"]["UV_PYTHON"] == str(environment.python) + assert os.environ["PM_WORKSPACE_TEST_SENTINEL"] == "live" diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index dd6a6b4b7d..99071a176c 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -8,6 +8,7 @@ import sys import pytest from pm import workspace +from pm.environment import managed_environment @pytest.fixture(autouse=True) @@ -37,7 +38,8 @@ def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch): uv = shutil.which("uv") assert uv is not None monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) - workspace.lock_and_sync([], [], root=root, venv_dir=venv) + workspace.lock_and_sync([], [], root=root, source=core, seed_lock=None, + environment=managed_environment(venv)) python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") probe = subprocess.run([str(python), "-c", "import buildable_core; print(buildable_core.VALUE)"], cwd=tmp_path, text=True, capture_output=True, check=True, timeout=30) @@ -57,13 +59,19 @@ def test_source_refresh_does_not_need_metadata_change_and_refuses_live_root(tmp_ uv = shutil.which("uv") assert uv monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) - workspace.lock_and_sync([], root=staged, venv_dir=tmp_path / "env") + workspace.lock_and_sync([], [], root=staged, source=core, seed_lock=None, + environment=managed_environment(tmp_path / "env")) (core / "code.py").write_text("VALUE = 2\n") - workspace.lock_and_sync([], root=staged, venv_dir=tmp_path / "env") + fresh = tmp_path / "fresh" + workspace.lock_and_sync([], [], root=fresh, source=core, seed_lock=staged / "uv.lock", + environment=managed_environment(tmp_path / "fresh-env")) + assert (staged / "code.py").read_text() == "VALUE = 1\n" + staged = fresh assert (staged / "code.py").read_text() == "VALUE = 2\n" before = (core / "code.py").read_bytes() - with pytest.raises(workspace.InstallError, match="source"): - workspace.lock_and_sync([], root=core, venv_dir=tmp_path / "env") + with pytest.raises(workspace.InstallError, match="fresh"): + workspace.lock_and_sync([], [], root=core, source=core, seed_lock=None, + environment=managed_environment(tmp_path / "env")) assert (core / "code.py").read_bytes() == before @@ -86,7 +94,8 @@ def test_legacy_member_is_generated_only_inside_workspace(tmp_path, monkeypatch) assert uv monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) generated = tmp_path / "stage" - workspace.lock_and_sync([plugin], root=generated, venv_dir=tmp_path / "env") + workspace.lock_and_sync([plugin], [], root=generated, source=core, seed_lock=None, + environment=managed_environment(tmp_path / "env")) metadata = tomllib.loads((generated / "pyproject.toml").read_text()) member = (generated / metadata["tool"]["uv"]["workspace"]["members"][0]).resolve() assert member.is_relative_to(generated) @@ -134,7 +143,8 @@ def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_pat monkeypatch.setattr(workspace.paths, "repo_root", lambda: core) monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) baseline, first_env = tmp_path / "baseline", tmp_path / "first-env" - workspace.lock_and_sync([], [], root=baseline, venv_dir=first_env) + workspace.lock_and_sync([], [], root=baseline, source=core, seed_lock=None, + environment=managed_environment(first_env)) first_lock = (baseline / "uv.lock").read_bytes() assert next(p["version"] for p in tomllib.loads(first_lock.decode())["package"] if p["name"] == "pkgb") == "1.2" _wheel(wheels, "pkgb", "1.3") @@ -147,9 +157,11 @@ def test_plugin_can_move_compatible_transitive_but_not_exact_requirement(tmp_pat extended, candidate = tmp_path / "extended", tmp_path / "candidate" if exact: with pytest.raises(workspace.ResolutionConflict): - workspace.lock_and_sync([plugin], [], root=extended, venv_dir=candidate, seed_lock=baseline / "uv.lock") + workspace.lock_and_sync([plugin], [], root=extended, source=core, seed_lock=baseline / "uv.lock", + environment=managed_environment(candidate)) else: - workspace.lock_and_sync([plugin], [], root=extended, venv_dir=candidate, seed_lock=baseline / "uv.lock") + workspace.lock_and_sync([plugin], [], root=extended, source=core, seed_lock=baseline / "uv.lock", + environment=managed_environment(candidate)) installed = tomllib.loads((extended / "uv.lock").read_text(encoding="utf-8"))["package"] assert next(p["version"] for p in installed if p["name"] == "pkgb") == "1.3" python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") diff --git a/tests/pm/test_workspace_output_encoding.py b/tests/pm/test_workspace_output_encoding.py index 5a21c2d5f3..0563f52f4a 100644 --- a/tests/pm/test_workspace_output_encoding.py +++ b/tests/pm/test_workspace_output_encoding.py @@ -42,7 +42,9 @@ def test_uv_failure_retains_utf8_build_diagnostic( diagnostic = "🔍 cryptography: OpenSSL headers not found" raw = diagnostic.encode("utf-8") + suffix + b"\n" expected = diagnostic + ("�" if suffix else "") - monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (tmp_path, False)) + core = tmp_path / "core" + core.mkdir() + (core / "pyproject.toml").write_text('[project]\nname="test-core"\nversion="1"\n') from pm.environment import PythonEnvironment environment = PythonEnvironment( @@ -62,8 +64,8 @@ def test_uv_failure_retains_utf8_build_diagnostic( monkeypatch.setattr("pm.environment.subprocess.run", run_uv) with pytest.raises(InstallError) as excinfo: - ws.lock_and_sync([], venv_dir=environment.destination, root=tmp_path, - source=tmp_path, environment=environment) + ws.lock_and_sync([], [], root=tmp_path / "workspace", source=core, + seed_lock=None, environment=environment) assert type(excinfo.value) is InstallError # A build error is not a resolver conflict. assert excinfo.value.cause == f"uv {stage} exited 17: {expected}" diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py deleted file mode 100644 index 597c0cbc3c..0000000000 --- a/tests/test_managed_runtime_resolution.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Workspace commands preserve PM's toolchain instead of consulting PATH.""" -import importlib -import shutil -import subprocess - -import pytest - -from pm import workspace -from pm.package import InstallError - - -def test_workspace_uv_preserves_managed_tool_and_interpreter(monkeypatch, tmp_path): - ensure = importlib.import_module("pm.ensure") - monkeypatch.setattr(ensure, "uv", lambda **kwargs: ("managed/uv", {"UV_PYTHON": "managed/python", "UV_CACHE_DIR": str(tmp_path / "cache")})) - monkeypatch.setattr(workspace, "_generate_pyproject", lambda *args, **kwargs: (tmp_path, False)) - def reject_path(*args, **kwargs): - raise AssertionError("workspace commands must not resolve tools from PATH") - monkeypatch.setattr(shutil, "which", reject_path) - calls = [] - def run(cmd, **kwargs): - calls.append((cmd, kwargs["env"])) - return subprocess.CompletedProcess(cmd, 0, "", "") - monkeypatch.setattr(workspace.subprocess, "run", run) - workspace.lock_and_sync([], venv_dir=tmp_path / "venv", root=tmp_path) - assert [cmd[1] for cmd, _ in calls] == ["lock", "sync"] - assert all(cmd[0] == "managed/uv" and env["UV_PYTHON"] == "managed/python" for cmd, env in calls) - - -def test_workspace_uv_missing_toolchain_never_falls_back(monkeypatch, tmp_path): - ensure = importlib.import_module("pm.ensure") - monkeypatch.setattr(ensure, "uv", lambda **kwargs: (None, {})) - monkeypatch.setattr(workspace, "_generate_pyproject", lambda *args, **kwargs: (tmp_path, False)) - monkeypatch.setattr(shutil, "which", lambda name: "developer/uv") - with pytest.raises(InstallError, match="PM's uv and Python"): - workspace.lock_and_sync([], venv_dir=tmp_path / "venv", root=tmp_path) From ea7299062cd92fd0a71001858c9ce546dfa37afb Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:54 -0400 Subject: [PATCH 04/33] Remove unreachable parallel Store publisher --- .github/workflows/desktop-bundled-release.yml | 165 ++---------------- 1 file changed, 11 insertions(+), 154 deletions(-) diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 240f25f201..57033254a0 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -7,20 +7,19 @@ name: Desktop Bundled Release # files + receipts to R2 (releases/tag//) → gated publish jobs write # feeds → builds table. Stable candidates stop before feed publication. # -# Per-OS topology (MSIX work is gated on the WINDOWS build only — mac/linux -# legs never block the win32 feed or Store submission): +# Per-OS topology (canary feeds follow their own platform builds; stable +# publication and Store submission require the accepted cross-platform candidate): # # build-win32 (win32-x64 + win32-arm64) → stage packages + receipts to R2 # build-darwin (darwin-arm64 + darwin-x64) → sign + notarize + stage # dmg/zip/blockmap + per-arch feed inputs to the R2 tag archive # build-linux → DISABLED for now (dummy skips) -# publish-win32-updater → App Installer feed -# (needs build-win32): releases/win32//*.appinstaller + -# *.msixbundle (stage-msixbundle.mjs --variant bundled) -# publish-win32-store → Windows Store submission -# (needs build-win32, PARALLEL with the updater feed): bundle the two -# Store-*.msix into one universal Store .msixbundle and submit via the -# MSStore CLI. Only stable is eligible, gated on MS_STORE_PRODUCT_ID. +# publish-win32-updater → native universal bundles +# (needs build-win32): canary also publishes its App Installer feed; +# stable candidates stage sideload + Store bundles without touching feeds. +# stable-store → verified Store submission +# (needs stable-publish): materialize the immutable accepted Store bundle +# and submit via the MSStore CLI without rebuilding. # publish-darwin-updater → macOS electron-updater feed # (needs BOTH darwin legs): scripts.releases.r2 finalize merges the per-arch # ymls into releases/darwin//-mac.yml — the feed @@ -29,7 +28,7 @@ name: Desktop Bundled Release # # Feed layout (matches apps/desktop/electron/app-updater.ts's arms): # releases/win32//.appinstaller App Installer feed -# releases/win32//*.msixbundle (publish-win32-updater) +# canary bundles live beside the feed; stable points into releases/tag// # releases/darwin//-mac.yml electron-updater feed # (dmg/zip live once in releases/tag//; the merged feed points at # them with absolute object keys) @@ -59,13 +58,13 @@ name: Desktop Bundled Release # non-secret vars. scripts/releases/r2.py derives the S3 endpoint from # the account id. Upload/list operations need only Python; feed operations use ruamel.yaml. # -# Windows Store submission (publish-win32-store): MSStore CLI via +# Windows Store submission (stable-store): MSStore CLI via # microsoft/microsoft-store-apppublisher. Credentials live in the # release-signing environment: # secrets: MS_STORE_TENANT_ID, MS_STORE_SELLER_ID, MS_STORE_CLIENT_ID, # MS_STORE_CLIENT_SECRET # vars: MS_STORE_PRODUCT_ID (the Partner Center product ID) -# msstore reconfigure → (delete pending) → msstore publish +# msstore reconfigure → msstore publish # -id # The Store bundle is UNSIGNED on purpose — Partner Center re-signs on # ingestion (see sign-msix.mjs). @@ -1023,148 +1022,6 @@ jobs: --name windows-universal --root apps/desktop/release --include '*.msixbundle' fi - # ── Windows Store submission (REAL — PARALLEL with publish-win32-updater) ─ - # Bundles the two Store-*.msix into one universal Store .msixbundle and - # submits it to the Windows Store via the MSStore CLI. - # - # Only stable releases can claim the official Partner Center identity. - # Canary and commit builds are sideload-only, even when a flight exists. - publish-win32-store: - name: Publish the stable Windows Store submission - needs: [validate, build-win32] - if: | - inputs.build_commit == '' - && inputs.upload_release == true - && vars.MS_STORE_PRODUCT_ID != '' - && contains(inputs.tag, '-canary.') == false - runs-on: windows-2025 - environment: release-signing - timeout-minutes: 60 - env: - HERMES_PAYLOAD_TAG: ${{ inputs.tag }} - ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder - CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} - CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} - CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} - CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} - CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} - MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} - MS_STORE_SELLER_ID: ${{ secrets.MS_STORE_SELLER_ID }} - MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} - MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} - MS_STORE_PRODUCT_ID: ${{ vars.MS_STORE_PRODUCT_ID }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Privileged job: pin to the SHA validate admitted, not the tag. - ref: ${{ needs.validate.outputs.sha }} - fetch-tags: true - - - name: Set up the locked build toolchain - uses: ./.github/actions/setup-pm - with: - toolchain: all - archive-inputs: true - cache-python: false - - - name: Install the locked Windows bundle tooling - uses: ./.github/actions/retry - with: - # No Electron/native postinstalls: only the builder's SDK downloader. - command: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund - - - name: Resolve toolchain cache key - id: toolchain - shell: bash - run: | - node -e ' - const l = require("./package-lock.json") - const el = l.packages["apps/desktop/node_modules/electron"].version - const eb = l.packages["node_modules/electron-builder"].version - if (!el || !eb) process.exit(1) - console.log(`electron=${el}`) - console.log(`builder=${eb}`) - ' >> "$GITHUB_OUTPUT" - - # Reuse the build cache when available. The bundle script also works - # cold by provisioning the same SDK through the pinned builder. - - name: Resolve electron's default download cache path - shell: bash - run: | - case "$RUNNER_OS" in - Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; - macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; - *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; - esac - - - name: Cache electron + electron-builder toolchain - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ${{ github.workspace }}/.cache/electron-builder - ${{ github.workspace }}/.cache/electron - ${{ env.ELECTRON_DEFAULT_CACHE }} - key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} - restore-keys: | - eb2-${{ runner.os }}-${{ runner.arch }}- - - - name: Retrieve Store packages from R2 - shell: bash - env: - RELEASE_COMMIT: ${{ needs.validate.outputs.sha }} - run: | - python -m scripts.releases.handoff fetch --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ - --name win32-x64 --name win32-arm64 --root apps/desktop/release --include 'Store-*.msix' - - - name: Bundle the Store submission MSIX - id: storebundle - shell: bash - run: | - # The SDK downloader logs to stdout; the path has its own output. - result="$RUNNER_TEMP/store-bundle-path" - node scripts/bundle-store-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --output-file "$result" - bundle="$(< "$result")" - test -f "$bundle" - echo "bundle=$bundle" >> "$GITHUB_OUTPUT" - echo "Store bundle: $bundle" - - - name: Archive the Store bundle to the tag dir - shell: bash - run: | - # The per-arch Store-*.msix are already in the immutable archive - # (uploaded by the build legs); keep the assembled universal bundle - # there too as the record of exactly what was submitted. - python -m scripts.releases.r2 put \ - --tag "$HERMES_PAYLOAD_TAG" \ - --key "$(basename "${{ steps.storebundle.outputs.bundle }}")" \ - --file "${{ steps.storebundle.outputs.bundle }}" - - - name: Setup MSStore CLI - uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 - - - name: Configure Store credentials - shell: bash - run: | - msstore reconfigure \ - --tenantId "$MS_STORE_TENANT_ID" \ - --sellerId "$MS_STORE_SELLER_ID" \ - --clientId "$MS_STORE_CLIENT_ID" \ - --clientSecret "$MS_STORE_CLIENT_SECRET" - - - name: Publish the Store submission - shell: bash - run: | - # The universal Store .msixbundle is accepted directly by msstore - # publish (PackageFilesExtensionInclude: .msix/.msixbundle/.msixupload). - # Partner Center signs on ingestion — no signing here. - # - # Partner Center allows one pending submission. Clear that draft - # before publishing. Only the publish result gates this step. - bundle="${{ steps.storebundle.outputs.bundle }}" - msstore submission delete "$MS_STORE_PRODUCT_ID" --no-confirm \ - || echo "no pending submission to clear" - msstore publish "$bundle" -id "$MS_STORE_PRODUCT_ID" - # ── macOS updater channel (REAL) ─────────────────────────────────────────── # Merges the per-arch feed ymls (arm64-stable-mac.yml / x64-stable-mac.yml # …) into releases/darwin//-mac.yml via From 7417158acdb69851f094f9e61a1bb4b60cb4efb9 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:54 -0400 Subject: [PATCH 05/33] Let the shared output guard classify desktop products --- scripts/build/README.md | 4 ++++ scripts/build/desktop.mjs | 7 ++++--- scripts/build/frontend-common.mjs | 3 +++ tests-js/desktop-builder.test.mjs | 27 ++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/scripts/build/README.md b/scripts/build/README.md index 074e83d754..7f5c2fde17 100644 --- a/scripts/build/README.md +++ b/scripts/build/README.md @@ -120,6 +120,10 @@ marker. Files, symlinks, and source directories are rejected. The exact npm destinations (`ui-tui/dist`, `hermes_cli/web_dist`, `apps/desktop/dist`, and `apps/desktop/build/native-deps`) remain rebuildable without a prior marker. Other in-tree products live beneath `.build/` or `apps/desktop/build/products/`. +`frontend-common.mjs` classifies these destinations independently of which source +children already exist, so a warm desktop rebuild uses the same rule as a fresh +build. Explicit stamp, icon, native-tree and dependency inputs remain protected, +even when they live beneath a generated destination. ### Icons and native inputs diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index e140bf5158..ddd650589e 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -29,9 +29,10 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type if (!icons || !stamp || !nativeDeps) throw new Error('icons, stamp and nativeDeps are required prepared inputs') const app = 'apps/desktop' ;({ source, out } = productOutput(source, out, [ - ...readdirSync(join(resolve(source), app)).filter(name => name !== 'dist').map(name => `${app}/${name}`), - `${app}/scripts`, 'scripts/build', 'package.json', 'package-lock.json', - 'apps/shared', 'node_modules', ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), + // productOutput owns source/generated classification. Protect prepared + // inputs explicitly, including dependency symlinks outside the checkout. + `${app}/node_modules`, 'node_modules', + ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), ])) const publicIcons = join(resolve(icons), app, 'public') if (!existsSync(join(publicIcons, 'apple-touch-icon.png'))) throw new Error(`Missing desktop icon: ${join(publicIcons, 'apple-touch-icon.png')}`) diff --git a/scripts/build/frontend-common.mjs b/scripts/build/frontend-common.mjs index 2b2088f9ce..7a2284ef87 100644 --- a/scripts/build/frontend-common.mjs +++ b/scripts/build/frontend-common.mjs @@ -50,6 +50,9 @@ function requireOwnedOutput(out, source) { } } +// Classify in-tree destinations here, not by enumerating existing workspace +// children: generated parents also exist after the first build. inputs names +// explicit prepared trees/files; their protection wins even in generated homes. export function productOutput(source, out, inputs) { if (!source || !out) throw new Error('source and output paths are required') const src = realpathSync(path.resolve(source)) diff --git a/tests-js/desktop-builder.test.mjs b/tests-js/desktop-builder.test.mjs index ba93f804be..6eac116656 100644 --- a/tests-js/desktop-builder.test.mjs +++ b/tests-js/desktop-builder.test.mjs @@ -97,6 +97,31 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false) }, 60000) +test('in-tree desktop products rebuild after build exists without replacing prepared inputs', async () => { + const { buildDesktop } = await import('../scripts/build/desktop.mjs') + const { productCurrent } = await import('../scripts/build/freshness.mjs') + const input = fixture() + input.out = join(input.source, 'apps/desktop/build/products/desktop') + await buildDesktop(input) + put(join(input.source, 'apps/desktop/src/index.js'), 'document.getElementById("app").textContent = "warm rebuild"') + await buildDesktop(input) + expect(productCurrent({ ...input, product: 'desktop' })).toBe(true) + const assets = files(join(input.out, 'assets')).map(([, bytes]) => Buffer.from(bytes, 'base64').toString()).join('') + expect(assets).toContain('warm rebuild') + + // The generated-path allowance never overrides an explicitly prepared input, + // even when that input itself is an earlier builder-owned product. + const built = files(input.out) + for (const prepared of [ + { stamp: join(input.out, 'hermes-build.json') }, + { nativeDeps: join(input.out, 'node_modules') }, + { icons: input.out }, + ]) { + await expect(buildDesktop({ ...input, ...prepared })).rejects.toThrow(/overlap/) + expect(files(input.out)).toEqual(built) + } +}, 30000) + test('a prepared input changing during desktop compilation cannot publish a current receipt', async () => { const { buildDesktop } = await import('../scripts/build/desktop.mjs') const input = fixture() @@ -137,7 +162,7 @@ test('typecheck uses scratch state and incomplete prepared inputs fail before pu rmSync(join(input.icons, 'apps/desktop/public/apple-touch-icon.png')) await expect(buildDesktop(input)).rejects.toThrow(/icon/i) expect(files(input.out)).toEqual(built) - await expect(buildDesktop({ ...input, out: join(input.source, 'apps/desktop/scripts') })).rejects.toThrow(/overlap/) + await expect(buildDesktop({ ...input, out: join(input.source, 'apps/desktop/scripts') })).rejects.toThrow(/Output/) put(join(input.icons, 'apps/desktop/public/apple-touch-icon.png'), 'fresh icon') rmSync(join(input.nativeDeps, 'node-pty/build'), { recursive: true, force: true }) rmSync(join(input.nativeDeps, 'node-pty/prebuilds'), { recursive: true, force: true }) From 690316c589f0c8150ae940f6a76d483c90abcc08 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:59 -0400 Subject: [PATCH 06/33] Give release Python sole ownership of App Installer descriptors --- apps/desktop/scripts/gen-appinstaller.mjs | 71 ---------------- .../desktop/scripts/gen-appinstaller.test.mjs | 59 -------------- apps/desktop/scripts/msix-shared.test.mjs | 13 --- .../scripts/side-by-side.windows.test.mjs | 25 ++++-- docs/stable-releases.md | 10 +++ scripts/bundles/release_artifacts.py | 72 +++++++++++++++-- scripts/msix-shared.mjs | 55 +------------ scripts/stage-msixbundle.mjs | 80 +++---------------- .../e2e-assets/windows-bundled-helpers.mjs | 66 +++------------ .../windows-bundled-helpers.test.mjs | 33 +++++--- tests/install/windows-bundled-e2e.ps1 | 2 +- tests/scripts/test_appinstaller.py | 80 +++++++++++++++++++ tests/scripts/test_release_promotion.py | 9 ++- 13 files changed, 230 insertions(+), 345 deletions(-) delete mode 100644 apps/desktop/scripts/gen-appinstaller.mjs delete mode 100644 apps/desktop/scripts/gen-appinstaller.test.mjs create mode 100644 tests/scripts/test_appinstaller.py diff --git a/apps/desktop/scripts/gen-appinstaller.mjs b/apps/desktop/scripts/gen-appinstaller.mjs deleted file mode 100644 index 078d88fbc0..0000000000 --- a/apps/desktop/scripts/gen-appinstaller.mjs +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -// gen-appinstaller.mjs — generate the Windows App Installer (.appinstaller) -// file for an out-of-store MSIX channel feed. -// -// The out-of-store distribution is App Installer owned: each stable/canary -// channel dir under the feed host holds a universal .msixbundle plus an -// .appinstaller that (a) installs the bundle and (b) records the .appinstaller -// URI as the package's update source, so the OS can re-check it on launch. -// -// The identity comes from product-identity.cjs via scripts/msix-shared.mjs -// (the SAME single derivation as the package manifest), so the -// .appinstaller's MainBundle Name/Publisher always match the bundle's -// manifest. `store` has no appinstaller (the Store owns its distribution). -// -// Pure buildAppInstaller() lives in scripts/msix-shared.mjs and is -// unit-tested; this module is the CLI wrapper: -// node apps/desktop/scripts/gen-appinstaller.mjs --out --base-url -// Reads HERMES_DESKTOP_VARIANT (bundled|light), HERMES_PAYLOAD_TAG (channel) -// and package.json (version) like the rest of the build. -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -import { appIdentity, buildAppInstaller } from '../../../scripts/msix-shared.mjs' - -const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') - -const isCli = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href - -if (isCli) { - // node strips the first '--' (and an immediately-following option) for its - // own use; --out lands as a BARE arg. Parse space-separated flag pairs, - // not --flag=value, so the CLI survives that mangling. - const args = process.argv.slice(2) - const flagValue = (name) => { - for (let i = 0; i < args.length - 1; i += 1) { - if (args[i] === name) return args[i + 1] - } - return undefined - } - const out = flagValue('--out') - const baseUrl = flagValue('--base-url') || process.env.CLOUDFLARE_R2_PUBLIC_URL - - if (!out || !baseUrl) { - console.error('[gen-appinstaller] --out= and --base-url= (or CLOUDFLARE_R2_PUBLIC_URL) are required') - process.exit(1) - } - - const { identity, version, name } = appIdentity(desktop, process.env.HERMES_PAYLOAD_TAG) - if (!identity.channel) { - console.error('[gen-appinstaller] Store and commit builds have no App Installer feed') - process.exit(1) - } - - const canary = /-canary/.test(process.env.HERMES_PAYLOAD_TAG || '') - const variantDir = identity.light ? 'light/' : '' - const ch = canary ? 'canary' : 'stable' - const channelPath = `win32/${variantDir}${ch}` - - const xml = buildAppInstaller({ - baseUrl: String(baseUrl).replace(/\/+$/, ''), - variantChannelPath: channelPath, - identityName: identity.msixAppIdWithOrg, - version, - bundleFilename: `${name}-${version}-win.msixbundle` - }) - - fs.mkdirSync(path.dirname(out), { recursive: true }) - fs.writeFileSync(out, xml) - console.log(`[gen-appinstaller] wrote ${out} (${channelPath}/${name}-${version}-win.msixbundle)`) -} diff --git a/apps/desktop/scripts/gen-appinstaller.test.mjs b/apps/desktop/scripts/gen-appinstaller.test.mjs deleted file mode 100644 index 129e12607c..0000000000 --- a/apps/desktop/scripts/gen-appinstaller.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// gen-appinstaller — the .appinstaller document for an out-of-store channel. -// The identity inside must match the package manifest (same derivation), and -// the bundle/package URLs must resolve under the feed host. -import assert from 'node:assert/strict' - -import { describe, test } from 'vitest' - -import { OUT_OF_STORE_PUBLISHER, buildAppInstaller } from '../../../scripts/msix-shared.mjs' - -describe('buildAppInstaller', () => { - const base = { - baseUrl: 'https://updates.example.com', - variantChannelPath: 'win32/stable', - identityName: 'NousResearch.HermesBundled', - version: '0.3.0.0', - bundleFilename: 'HermesBundled-0.3.0.0-win.msixbundle' - } - - test('pins the same publisher as the out-of-store manifest (ATS cert subject)', () => { - const xml = buildAppInstaller(base) - assert.match(xml, /Publisher="CN=Nous Research Inc\., O=Nous Research Inc\., L=Austin, S=Texas, C=US"/) - assert.equal(OUT_OF_STORE_PUBLISHER, 'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US') - }) - - test('MainBundle points at the package bytes and self URI stays on the published channel descriptor', () => { - const xml = buildAppInstaller(base) - assert.match(xml, / { - const xml = buildAppInstaller(base) - assert.match(xml, /Name="NousResearch\.HermesBundled"/) - const versionCount = (xml.match(/Version="0\.3\.0\.0"/g) || []).length - // AppInstaller Version + MainBundle Version = 2 occurrences. - assert.equal(versionCount, 2) - }) - - test('UpdateSettings keeps the OS prompt off (the in-app checker owns the prompt)', () => { - const xml = buildAppInstaller(base) - assert.match(xml, //) - assert.doesNotMatch(xml, /ShowPrompt=/) - }) - - test('a variant channel path with a trailing slash still resolves under the host', () => { - const xml = buildAppInstaller({ ...base, variantChannelPath: 'win32/canary/' }) - assert.match(xml, /https:\/\/updates\.example\.com\/win32\/canary\//) - }) - - test('reserved XML characters in identity values are escaped', () => { - const xml = buildAppInstaller({ ...base, identityName: 'A&B' }) - assert.match(xml, /Name="A&B<App>"/) - }) -}) diff --git a/apps/desktop/scripts/msix-shared.test.mjs b/apps/desktop/scripts/msix-shared.test.mjs index fceddc18cf..5c094cc423 100644 --- a/apps/desktop/scripts/msix-shared.test.mjs +++ b/apps/desktop/scripts/msix-shared.test.mjs @@ -89,16 +89,3 @@ test('legacy 8-digit canary stamp still computes minutes (midnight of that day)' const minutes = msix.canaryBuildMinutesFor('v0.27.2-canary.20260801', base) assert.equal(minutes, 0) }) - -test('buildAppInstaller pins the derived 4-part version everywhere', () => { - const xml = msix.buildAppInstaller({ - baseUrl: 'https://updates.example.com', - variantChannelPath: 'win32/canary', - identityName: 'NousResearch.HermesBundled', - version: '0.27.2.1234', - bundleFilename: 'HermesBundled-0.27.2.1234-win.msixbundle' - }) - assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/canary\/HermesBundled-0\.27\.2\.1234-win\.msixbundle"/) - const versionCount = (xml.match(/Version="0\.27\.2\.1234"/g) || []).length - assert.equal(versionCount, 2) -}) diff --git a/apps/desktop/scripts/side-by-side.windows.test.mjs b/apps/desktop/scripts/side-by-side.windows.test.mjs index c4c744c948..33c557ebd5 100644 --- a/apps/desktop/scripts/side-by-side.windows.test.mjs +++ b/apps/desktop/scripts/side-by-side.windows.test.mjs @@ -48,7 +48,7 @@ async function nativeProof() { 'apps/desktop/product-identity.cjs', 'apps/desktop/electron-builder.config.cjs', 'apps/desktop/package.json', 'apps/desktop/update-feed.cjs', 'apps/desktop/update-feed.json', 'apps/desktop/assets/msix-manifest.xml', - ...['before-build', 'gen-msix-manifest', 'gen-appinstaller', 'mac-sign', 'payload-digests', 'write-build-stamp', 'utils'] + ...['before-build', 'gen-msix-manifest', 'mac-sign', 'payload-digests', 'write-build-stamp', 'utils'] .map(name => `apps/desktop/scripts/${name}.mjs`), 'scripts/msix-shared.mjs', 'scripts/release-content-types.json', 'scripts/build/python.mjs', ] @@ -159,13 +159,24 @@ foreach ($asset in @(@('Square44x44Logo.png',44,44), @('Square150x150Logo.png',1 } } const descriptor = path.join(root, `${label}.appinstaller`) - const generated = run(process.execPath, [path.join(desktop, 'scripts/gen-appinstaller.mjs'), '--out', descriptor, '--base-url', 'https://example.invalid/fixture'], { cwd: work, env: childEnv }) - if (flavorEnv.HERMES_BUILD_COMMIT) { - check(generated.status !== 0 && !fs.existsSync(descriptor), `${label}: commit build emitted App Installer feed`) - check(facts.config.publish === null, `${label}: commit build config still publishes`) + if (facts.identity.channel) { + const publisher = attribute(roundtrip, 'Identity', 'Publisher') + const selfUri = `https://example.invalid/fixture/${facts.identity.channel}.appinstaller` + const artifactUri = `https://example.invalid/fixture/${facts.app.name}-${version}-win.msixbundle` + checked(process.env.HERMES_PYTHON || 'python', [ + '-m', 'scripts.bundles.release_artifacts', 'appinstaller', '--root', root, '--out', descriptor, + '--identity', name, '--publisher', publisher, '--version', version, + '--self-uri', selfUri, '--artifact-uri', artifactUri, + ], { cwd: repo, env: childEnv }) + const feed = fs.readFileSync(descriptor, 'utf8') + check(attribute(feed, 'MainBundle', 'Name') === name, `${label}: App Installer targets another family`) + check(attribute(feed, 'MainBundle', 'Publisher') === publisher, `${label}: App Installer changed publisher`) + check(attribute(feed, 'MainBundle', 'Version') === version, `${label}: App Installer changed version`) + check(attribute(feed, 'AppInstaller', 'Uri') === selfUri, `${label}: App Installer changed subscription`) + check(attribute(feed, 'MainBundle', 'Uri') === artifactUri, `${label}: App Installer changed artifact`) } else { - assert.equal(generated.status, 0, generated.output) - check(attribute(fs.readFileSync(descriptor, 'utf8'), 'MainBundle', 'Name') === name, `${label}: App Installer targets another family`) + check(!fs.existsSync(descriptor), `${label}: commit build emitted App Installer feed`) + check(facts.config.publish === null, `${label}: commit build config still publishes`) } const row = { label, name, version, aliases, packageDir, cliName: facts.identity.cliName, commit: flavorEnv.HERMES_BUILD_COMMIT || null } rows.push(row) diff --git a/docs/stable-releases.md b/docs/stable-releases.md index 5cbd350c4f..25c5b956f6 100644 --- a/docs/stable-releases.md +++ b/docs/stable-releases.md @@ -153,6 +153,16 @@ in JavaScript or PowerShell. These adapters consume release facts rather than reimplementing the release gate. Gate jobs use only Python's standard library; they do not install the application or the JS workspace to report a verdict. +`scripts.bundles.release_artifacts` owns App Installer XML and feed publication. +Its serializer takes explicit package identity, publisher, version, subscription +URI and artifact URI. Stable promotion uses the accepted candidate metadata; +canary publication verifies the native bundle manifest against the adapter's +expected identity before uploading the bundle, then the descriptor. Native SDK +bundling/signing stays in `stage-msixbundle.mjs`. Store and commit builds stop +before that feed handoff; `stable-store` submits the verified candidate without +rebuilding it. Native acceptance uses the same Python serializer, including its +12-hour on-launch check policy. + Signing and publication credentials stay in their protected job environments. The source CI call does not inherit deployment secrets. Configure the existing release-signing and container-publish environments before running this pipeline. diff --git a/scripts/bundles/release_artifacts.py b/scripts/bundles/release_artifacts.py index 01588c0960..7dc580d63d 100644 --- a/scripts/bundles/release_artifacts.py +++ b/scripts/bundles/release_artifacts.py @@ -181,20 +181,59 @@ def publish(manifest: dict, root: Path, public_base: str) -> None: put(tag=manifest["tag"], key=f"releases/termux/stable/{rel}", file=file, key_is_full=True, immutable=True) +def write_appinstaller(out: Path, *, identity: str, publisher: str, version: str, + self_uri: str, artifact_uri: str) -> None: + """Serialize verified package facts; callers own channel/acceptance policy.""" + if not all((identity, publisher, version, self_uri, artifact_uri)): + raise ValueError("Explicit identity, publisher, version, self URI and artifact URI are required") + ns = "http://schemas.microsoft.com/appx/appinstaller/2017/2" + ET.register_namespace("", ns) + descriptor = ET.Element(f"{{{ns}}}AppInstaller", {"Uri": self_uri, "Version": version}) + ET.SubElement(descriptor, f"{{{ns}}}MainBundle", { + "Name": identity, "Publisher": publisher, "Version": version, "Uri": artifact_uri, + }) + settings = ET.SubElement(descriptor, f"{{{ns}}}UpdateSettings") + ET.SubElement(settings, f"{{{ns}}}OnLaunch", {"HoursBetweenUpdateChecks": "12"}) + out.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(descriptor).write(out, encoding="utf-8", xml_declaration=True) + + +def publish_canary_appinstaller(root: Path, *, tag: str, variant: str, bundle: Path, + identity: str, publisher: str, version: str, public_base: str) -> None: + """Native SDK work is complete; verify its identity before writing a feed.""" + from scripts.releases.r2 import put + + if not re.fullmatch(r"v\d+\.\d+\.\d+-canary\.20\d{6}(?:\d{6})?", tag or ""): + raise ValueError("Only canary feeds publish directly; stable requires accepted candidates") + if variant not in ("bundled", "light"): + raise ValueError("Store and commit builds have no App Installer feed") + if not public_base: + raise ValueError("Public feed base URL is required") + with zipfile.ZipFile(bundle) as archive: + native = ET.fromstring(archive.read("AppxMetadata/AppxBundleManifest.xml")).find("{*}Identity") + for attr, expected in (("Name", identity), ("Publisher", publisher), ("Version", version)): + if native is None or native.get(attr) != expected: + raise ValueError(f"Universal bundle {attr} does not match its expected identity") + directory = f"releases/win32/{'light/' if variant == 'light' else ''}canary" + base = public_base.rstrip("/") + descriptor = root / "canary.appinstaller" + write_appinstaller(descriptor, identity=identity, publisher=publisher, version=version, + self_uri=f"{base}/{directory}/{descriptor.name}", + artifact_uri=f"{base}/{directory}/{bundle.name}") + # A failed upload or HEAD verification leaves the previous pointer intact. + put(tag=tag, key=f"{directory}/{bundle.name}", file=bundle, key_is_full=True, immutable=True) + put(tag=tag, key=f"{directory}/{descriptor.name}", file=descriptor, key_is_full=True) + + def promote(manifest: dict, root: Path, public_base: str) -> None: from scripts.releases.r2 import put, finalize materialize(manifest, root, public_base=public_base) windows = next(r for r in manifest["packages"] if r["platform"] == "windows") uri = f"{public_base.rstrip('/')}/releases/win32/stable/stable.appinstaller" - ns = "http://schemas.microsoft.com/appx/appinstaller/2017/2" - ET.register_namespace("", ns) - descriptor = ET.Element(f"{{{ns}}}AppInstaller", {"Uri": uri, "Version": windows["version"]}) - ET.SubElement(descriptor, f"{{{ns}}}MainBundle", {"Name": windows["identity"], "Publisher": windows["publisher"], "Version": windows["version"], "Uri": windows["artifact"]["url"]}) - settings = ET.SubElement(descriptor, f"{{{ns}}}UpdateSettings") - ET.SubElement(settings, f"{{{ns}}}OnLaunch", {"HoursBetweenUpdateChecks": "12"}) appinstaller = root / "stable.appinstaller" - ET.ElementTree(descriptor).write(appinstaller, encoding="utf-8", xml_declaration=True) + write_appinstaller(appinstaller, identity=windows["identity"], publisher=windows["publisher"], + version=windows["version"], self_uri=uri, artifact_uri=windows["artifact"]["url"]) apt = root / "apt" indexes = sorted(p for p in apt.rglob("*") if p.is_file() and not p.relative_to(apt).as_posix().startswith("pool/") and "/by-hash/" not in p.relative_to(apt).as_posix()) indexes.sort(key=lambda p: p.name == "InRelease") @@ -218,7 +257,7 @@ def promote(manifest: dict, root: Path, public_base: str) -> None: def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["record", "assemble", "publish", "promote", "materialize"]) + parser.add_argument("command", choices=["record", "assemble", "publish", "promote", "materialize", "appinstaller", "publish-appinstaller"]) parser.add_argument("--platform", choices=["windows", "macos", "termux"]) parser.add_argument("--arch") parser.add_argument("--root", type=Path, required=True) @@ -227,7 +266,24 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--commit", default=os.environ.get("GITHUB_SHA")) parser.add_argument("--public-base", default=os.environ.get("CLOUDFLARE_R2_PUBLIC_URL")) parser.add_argument("--store-only", action="store_true") + for name in ("identity", "publisher", "version", "self-uri", "artifact-uri"): + parser.add_argument(f"--{name}") + parser.add_argument("--variant", choices=["bundled", "light"]) + parser.add_argument("--bundle", type=Path) args = parser.parse_args(argv) + if args.command == "publish-appinstaller": + if not args.bundle: + parser.error("publish-appinstaller requires --bundle") + publish_canary_appinstaller(args.root, tag=args.tag, variant=args.variant, bundle=args.bundle, + identity=args.identity, publisher=args.publisher, version=args.version, + public_base=args.public_base) + return + if args.command == "appinstaller": + if not args.out: + parser.error("appinstaller requires --out") + write_appinstaller(args.out, identity=args.identity, publisher=args.publisher, + version=args.version, self_uri=args.self_uri, artifact_uri=args.artifact_uri) + return if args.command == "record": record(args.platform, args.arch, args.root, args.tag, args.commit, args.out) elif args.command == "assemble": diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index 70a31f3da0..bf3baf23e4 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -1,10 +1,5 @@ -// msix-shared.mjs — the shared MSIX-distribution building blocks used by -// BOTH the out-of-store feed generator (apps/desktop/scripts/gen-appinstaller.mjs) -// and the release job that stages the feed (scripts/stage-msixbundle.mjs). -// -// The two call sites must agree on every name/URL that Windows keys on — the -// .appinstaller's MainBundle identity and the bundle URI — so the XML -// builder and the version/filename derivations live here, once. +// Native MSIX identity/version derivation and artifact content types. +// App Installer XML and feed publication belong to scripts.bundles.release_artifacts. import fs from 'node:fs' import path from 'node:path' @@ -50,52 +45,6 @@ export function contentTypeFor(filename) { return undefined } -/** - * @param {unknown} value any value to XML-escape - * @returns {string} - */ -function escapeAttr(value) { - return String(value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - -/** - * Build an .appinstaller document for a channel. - * - * @param {{ - * baseUrl: string // feed host root (no trailing slash) - * variantChannelPath: string // e.g. "win32/", "win32/light/", "win32/canary/" - * identityName: string // package Identity Name (e.g. "NousResearch.HermesBundled") - * version: string // 4-part MSIX version, e.g. "1.2.3.0" - * bundleFilename: string // the universal .msixbundle filename in the feed dir - * descriptorFilename?: string // defaults to the channel's .appinstaller name - * }} o - * @returns {string} the .appinstaller XML - */ -export function buildAppInstaller(o) { - const directory = [o.baseUrl.replace(/\/+$/, ''), o.variantChannelPath.replace(/^\/+|\/+$/g, '')].filter(Boolean).join('/') - const bundleUrl = `${directory}/${o.bundleFilename}` - const descriptor = o.descriptorFilename || `${o.variantChannelPath.replace(/\/+$/, '').split('/').pop()}.appinstaller` - const appinstallerUri = `${directory}/${descriptor}` - - return [ - '', - '', - ' `, - ' ', - ' ', - ' ', - '', - '' - ].join('\n') -} - // The canary tag base + embedded UTC stamp: v0.27.2-canary.20260829034013 // (8- or 14-digit; the shorter legacy form is midnight of that day). The // base is the next PATCH over the newest stable, so the first three MSIX diff --git a/scripts/stage-msixbundle.mjs b/scripts/stage-msixbundle.mjs index aa1734907e..9ca82001eb 100644 --- a/scripts/stage-msixbundle.mjs +++ b/scripts/stage-msixbundle.mjs @@ -1,35 +1,17 @@ #!/usr/bin/env node -// stage-msixbundle.mjs — the out-of-store MSIX distribution job. -// -// Runs on a Windows runner of the release workflow AFTER all legs built -// (needs: build). Two responsibilities: -// -// 1. OUT-OF-STORE FEED: bundle the x64 + arm64 per-arch .msix into one -// universal .msixbundle, sign the bundle envelope, write the per-channel -// .appinstaller, and upload both to the win32 feed dirs — bundle FIRST, -// .appinstaller pointer LAST (a failed bundle upload leaves the previous -// feed intact): -// releases/win32//-.win.msixbundle -// releases/win32//stable.appinstaller (or canary.*) -// The .appinstaller is the install + auto-update entry point; the bundle -// is what the OS installs and swaps on update. Per-arch .msix files stay -// in the immutable releases/tag// archive (uploaded by the legs). -// -// 2. STORE ARCHIVE: re-upload the Store-submission .msix files (built by -// the win legs, prefixed Store-) to the tag archive. The Store is the -// distribution for those — they never touch a feed dir. -// -// Usage (win runner, bash): -// node scripts/stage-msixbundle.mjs --tag vX.Y.Z [--variant bundled|light] -// Reads HERMES_DESKTOP_VARIANT (bundled|light) from the environment; the -// workflow runs this job once per variant. +// Native Windows adapter: bundle x64 + arm64 MSIX packages and sign the +// envelope. Candidate/commit modes stop at the artifact. Canary releases hand +// the completed bundle and expected identity to release Python, which verifies +// the manifest and publishes the bundle before its App Installer pointer. +// Usage: node scripts/stage-msixbundle.mjs --tag vX.Y.Z --candidate +// node scripts/stage-msixbundle.mjs --tag vX.Y.Z-canary.STAMP import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' -import { appIdentity, buildAppInstaller } from './msix-shared.mjs' +import { appIdentity, OUT_OF_STORE_PUBLISHER } from './msix-shared.mjs' import { ensureWindowsBundleTools } from '../apps/desktop/scripts/windows-bundle-tools.mjs' @@ -94,8 +76,6 @@ if (process.platform !== 'win32') { const canary = /-canary\./.test(tag) if (!commitBuild && !canary && !candidate) throw new Error('Stable bundles must use the staged stable-release workflow') -const channel = canary ? 'canary' : 'stable' -const channelDir = `releases/win32/${variant === 'light' ? 'light/' : ''}${channel}` const desktop = path.join(REPO_ROOT, 'apps', 'desktop') const releaseDir = path.join(desktop, 'release') @@ -193,44 +173,10 @@ if (commitBuild) { process.exit(0) } -// ── 2. .appinstaller + uploads ───────────────────────────────────────────── -const baseUrl = String(process.env.CLOUDFLARE_R2_PUBLIC_URL || '').replace(/\/+$/, '') -if (!baseUrl) { - console.error('[stage-msixbundle] CLOUDFLARE_R2_PUBLIC_URL is required (feed dir URLs come from it)') - process.exit(1) -} - -const appinstaller = buildAppInstaller({ - baseUrl, - variantChannelPath: channelDir, - identityName: identity.msixAppIdWithOrg, - version, - bundleFilename: `${name}-${version}-win.msixbundle` -}) -const appinstallerName = `${channel}.appinstaller` -fs.writeFileSync(path.join(releaseDir, appinstallerName), appinstaller) - -const upload = (key, file, keyIsFull = true) => { - // NOTE: no fs.readFileSync here — the msixbundle can exceed Node's 2GiB - // buffer limit (ERR_FS_FILE_TOO_LARGE). scripts.releases.r2 put reads + hashes - // the file itself; log the size via stat instead. - const { size } = fs.statSync(file) - console.log(`[stage-msixbundle] upload ${key} (${size} bytes)`) - // Feed-dir keys are FULL object keys (releases/win32//…) — pass - // --key-is-full so r2 put does NOT wrap them under releases/tag//. - // scripts.releases.r2 put derives Content-Type from the key extension. - execFileSync(process.env.HERMES_PYTHON || 'python', ['-m', 'scripts.releases.r2', 'put', '--tag', tag, '--key', key, '--file', file, ...(keyIsFull ? ['--key-is-full'] : [])], { - cwd: REPO_ROOT, - stdio: 'inherit' - }) -} - -// C22 ordering: bundle FIRST, pointer LAST. scripts.releases.r2 PUTs then -// HEAD-verifies the remote content-length — a failed/short upload throws -// and aborts this job before the pointer is written. -upload(`${channelDir}/${name}-${version}-win.msixbundle`, bundle) -upload(`${channelDir}/${appinstallerName}`, path.join(releaseDir, appinstallerName)) - -// The Store-submission .msix files were already uploaded to the tag archive -// by the win legs (Store- prefix); nothing for this job to re-upload. +// Python owns descriptor serialization, identity verification and publication. +execFileSync(process.env.HERMES_PYTHON || 'python', [ + '-m', 'scripts.bundles.release_artifacts', 'publish-appinstaller', + '--root', releaseDir, '--bundle', bundle, '--tag', tag, '--variant', variant, + '--identity', identity.msixAppIdWithOrg, '--publisher', OUT_OF_STORE_PUBLISHER, '--version', version, +], { cwd: REPO_ROOT, stdio: 'inherit' }) console.log('[stage-msixbundle] done — feed manifests + bundle staged') diff --git a/tests/install/e2e-assets/windows-bundled-helpers.mjs b/tests/install/e2e-assets/windows-bundled-helpers.mjs index 0ada4665cb..ef19feacc4 100644 --- a/tests/install/e2e-assets/windows-bundled-helpers.mjs +++ b/tests/install/e2e-assets/windows-bundled-helpers.mjs @@ -1,22 +1,17 @@ -// windows-bundled-helpers.mjs — pure helpers + small CLI for the Windows -// packaged-app (MSIX / App Installer) E2E arm (tests/install/windows-bundled-e2e.ps1). -// -// Everything OS-visible here derives from the PRODUCTION builders in -// scripts/msix-shared.mjs (buildAppInstaller, OUT_OF_STORE_PUBLISHER, -// contentTypeFor) so the test feed cannot drift from the real feed. The -// production module's path is a CLI arg (--msix-shared) so the primary -// checkout's copy (with the parent-owned MainBundle/descriptorFilename -// support) is used read-only; the in-repo file is the fallback. +// Windows packaged-app acceptance helpers. Release Python owns descriptor XML; +// msix-shared supplies the expected publisher and content types. --msix-shared +// selects the checkout whose release module and native facts are under test. // // CLI (space-separated flag pairs — `node script.mjs -- --flag value` also // works; see the repo AGENTS note on Node eating `--` args): // node windows-bundled-helpers.mjs validate-manifest --manifest --msix-shared // node windows-bundled-helpers.mjs descriptor --feed --base-url -// --identity --version <4-part> --bundle +// --identity --publisher --version <4-part> --bundle // [--descriptor-filename update.appinstaller] [--msix-shared ] // node windows-bundled-helpers.mjs serve --feed --port-file import fs from 'node:fs' +import { execFileSync } from 'node:child_process' import path from 'node:path' import http from 'node:http' import { pathToFileURL, fileURLToPath } from 'node:url' @@ -158,24 +153,6 @@ export function feedLayout(feedDir, manifest) { } } -/** - * The arguments for the production buildAppInstaller for one feed side. - * variantChannelPath is '' — the side is folded into baseUrl (context - * contract), and the descriptor Uri is the optional descriptorFilename - * (parent-owned fix; defaults to the bundle filename with .appinstaller). - * @param {{ baseUrl: string, identityName: string, version: string, bundleFilename: string, descriptorFilename?: string }} o - */ -export function descriptorArgs(o) { - return { - baseUrl: o.baseUrl, - variantChannelPath: '', - identityName: o.identityName, - version: o.version, - bundleFilename: o.bundleFilename, - ...(o.descriptorFilename ? { descriptorFilename: o.descriptorFilename } : {}) - } -} - /** Load the production msix-shared module from an explicit path. */ export async function loadMsixShared(msixSharedPath) { const resolved = path.resolve(msixSharedPath) @@ -214,34 +191,15 @@ async function main() { } if (cmd === 'descriptor') { - const shared = await loadMsixShared(msixShared) - if (typeof shared.buildAppInstaller !== 'function') { - throw new Error('production buildAppInstaller missing — contract regression') - } - const xml = shared.buildAppInstaller(descriptorArgs({ - baseUrl: flags['base-url'], - identityName: flags.identity, - version: flags.version, - bundleFilename: flags.bundle, - descriptorFilename: flags['descriptor-filename'] || undefined - })) - // CONTRACT CHECK: the descriptor's own Uri must be the descriptor - // filename (the OS registers THIS uri as the update source), not a - // derived-from-bundle name. If the parent's descriptorFilename support - // is missing, the derived Uri would be .appinstaller instead. - if (flags['descriptor-filename']) { - const m = /Uri="([^"]+)"/.exec(xml) - if (!m || !m[1].endsWith(flags['descriptor-filename'])) { - throw new Error( - `buildAppInstaller ignored descriptorFilename: Uri=${m ? m[1] : '(none)'} ` + - `does not end with ${flags['descriptor-filename']} — parent msix-shared fix required` - ) - } - } const feed = path.resolve(flags.feed) - fs.mkdirSync(path.dirname(path.resolve(flags.out || path.join(feed, 'update.appinstaller'))), { recursive: true }) const outPath = flags.out || path.join(feed, 'update.appinstaller') - fs.writeFileSync(outPath, xml) + const base = flags['base-url'].replace(/\/+$/, '') + execFileSync(process.env.HERMES_PYTHON || 'python', [ + '-m', 'scripts.bundles.release_artifacts', 'appinstaller', '--root', feed, '--out', outPath, + '--identity', flags.identity, '--publisher', flags.publisher, '--version', flags.version, + '--self-uri', `${base}/${flags['descriptor-filename'] || 'update.appinstaller'}`, + '--artifact-uri', `${base}/${flags.bundle}`, + ], { cwd: path.resolve(path.dirname(msixShared), '..'), stdio: 'inherit' }) console.log(JSON.stringify({ ok: true, path: outPath })) return } diff --git a/tests/install/e2e-assets/windows-bundled-helpers.test.mjs b/tests/install/e2e-assets/windows-bundled-helpers.test.mjs index d03e51ec06..a062cb229a 100644 --- a/tests/install/e2e-assets/windows-bundled-helpers.test.mjs +++ b/tests/install/e2e-assets/windows-bundled-helpers.test.mjs @@ -1,17 +1,17 @@ // windows-bundled-helpers — unit tests for the pure helpers of the -// Windows packaged-app E2E arm. node:test, no production imports needed: -// the publisher rule takes the production constant as injected data (the -// driver binds it to OUT_OF_STORE_PUBLISHER at runtime, from the same -// module it builds the feed descriptors with). +// Windows packaged-app E2E arm. Pure manifest validation plus real descriptor +// CLI calls through the release Python module; no Windows installation needed. // // node --test tests/install/e2e-assets/windows-bundled-helpers.test.mjs import { test } from 'node:test' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { validateBundledManifest, fourPartNewer, feedLayout, descriptorArgs } from './windows-bundled-helpers.mjs' +import { validateBundledManifest, fourPartNewer, feedLayout } from './windows-bundled-helpers.mjs' const PUB = 'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US' const OTHER_PUB = 'CN=Someone Else' @@ -132,10 +132,21 @@ test('feedLayout stages per-side bundles and one swapped descriptor, rejecting c assert.throws(() => feedLayout('feed', m), /collide/) }) -test('descriptorArgs pass the contract shape (empty channel path, optional descriptor filename)', () => { - assert.deepEqual(descriptorArgs({ baseUrl: 'http://127.0.0.1:9/old', identityName: 'I', version: '0.3.0.0', bundleFilename: 'b.msixbundle' }), { - baseUrl: 'http://127.0.0.1:9/old', variantChannelPath: '', identityName: 'I', version: '0.3.0.0', bundleFilename: 'b.msixbundle' - }) - const withD = descriptorArgs({ baseUrl: 'u', identityName: 'I', version: 'v', bundleFilename: 'b', descriptorFilename: 'update.appinstaller' }) - assert.equal(withD.descriptorFilename, 'update.appinstaller') +test('native acceptance descriptor CLI uses explicit publisher and a stable subscription across upgrades', () => { + const feed = fs.mkdtempSync(path.join(os.tmpdir(), 'wbh-descriptor-')) + try { + for (const version of ['1.2.3.0', '1.2.4.31']) { + execFileSync(process.execPath, [fileURLToPath(new URL('./windows-bundled-helpers.mjs', import.meta.url)), + 'descriptor', '--feed', feed, '--base-url', 'http://127.0.0.1:9', + '--identity', 'Fixture.App', '--publisher', 'CN=Fixture & "Team"', + '--version', version, '--bundle', `${version}.msixbundle`, '--descriptor-filename', 'update.appinstaller', + ]) + const xml = fs.readFileSync(path.join(feed, 'update.appinstaller'), 'utf8') + assert.match(xml, /Publisher="CN=Fixture & "Team""/) + assert.match(xml, /Uri="http:\/\/127.0.0.1:9\/update.appinstaller"/) + assert.ok(xml.includes(`Uri="http://127.0.0.1:9/${version}.msixbundle"`)) + assert.equal((xml.match(new RegExp(`Version="${version.replaceAll('.', '\\.')}"`, 'g')) || []).length, 2) + assert.match(xml, /HoursBetweenUpdateChecks="12"/) + } + } finally { fs.rmSync(feed, { recursive: true, force: true }) } }) diff --git a/tests/install/windows-bundled-e2e.ps1 b/tests/install/windows-bundled-e2e.ps1 index b2bde460cc..718bda39d9 100644 --- a/tests/install/windows-bundled-e2e.ps1 +++ b/tests/install/windows-bundled-e2e.ps1 @@ -79,7 +79,7 @@ try { } $baseUrl = (Get-Content -Raw $PortFile).Trim() function Descriptor($Side, [string]$File) { - Run-Node @($Helper, 'descriptor', '--feed', $Feed, '--base-url', $baseUrl, '--identity', $Side.identity, '--version', $Side.version, '--bundle', $File, '--descriptor-filename', 'update.appinstaller') + Run-Node @($Helper, 'descriptor', '--feed', $Feed, '--base-url', $baseUrl, '--identity', $Side.identity, '--publisher', $Side.publisher, '--version', $Side.version, '--bundle', $File, '--descriptor-filename', 'update.appinstaller') } Descriptor $m.old 'old.msixbundle' $descriptor = Join-Path $Feed 'update.appinstaller' diff --git a/tests/scripts/test_appinstaller.py b/tests/scripts/test_appinstaller.py new file mode 100644 index 0000000000..d4724fc870 --- /dev/null +++ b/tests/scripts/test_appinstaller.py @@ -0,0 +1,80 @@ +"""App Installer descriptors bind explicit package facts to explicit feed URLs.""" +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from scripts.bundles import release_artifacts as artifacts +from tests.scripts.test_release_r2 import r2_server # noqa: F401 + + +@pytest.mark.parametrize('variant', ['bundled', 'light']) +def test_canary_publication_verifies_native_identity_and_uploads_bundle_before_pointer(tmp_path, r2_server, variant): + import zipfile + + tag, version = 'v1.2.4-canary.20260902000000', '1.2.4.1440' + identity, publisher = f'Fixture.{variant}.Canary', 'CN=Fixture & Team' + bundle = tmp_path / 'app.msixbundle' + manifest = ET.Element('Bundle') + ET.SubElement(manifest, 'Identity', {'Name': identity, 'Publisher': publisher, 'Version': version}) + with zipfile.ZipFile(bundle, 'w') as archive: + archive.writestr('AppxMetadata/AppxBundleManifest.xml', ET.tostring(manifest)) + base = 'https://releases.example' + directory = f"releases/win32/{'light/' if variant == 'light' else ''}canary" + pointer = f'{directory}/canary.appinstaller' + args = ['publish-appinstaller', '--root', str(tmp_path), '--tag', tag, '--variant', variant, + '--bundle', str(bundle), '--identity', identity, '--publisher', publisher, '--version', version, + '--public-base', base] + artifacts.main(args) + assert r2_server.store[f'{directory}/{bundle.name}'][0] == bundle.read_bytes() + descriptor = ET.fromstring(r2_server.store[pointer][0]) + assert descriptor.attrib == {'Uri': f'{base}/{pointer}', 'Version': version} + assert descriptor.find('{*}MainBundle').attrib == { + 'Name': identity, 'Publisher': publisher, 'Version': version, 'Uri': f'{base}/{directory}/{bundle.name}', + } + requests = [(method, path.rsplit('/', 1)[-1]) for method, path, _ in r2_server.requests] + assert requests.index(('HEAD', bundle.name)) < requests.index(('PUT', 'canary.appinstaller')) + previous = r2_server.store[pointer] + for option, bad in [('--identity', 'Wrong'), ('--publisher', 'CN=Wrong'), ('--version', '9.9.9.0'), + ('--tag', 'v1.2.4'), ('--tag', 'a' * 40), ('--variant', 'store')]: + invalid = list(args) + invalid[invalid.index(option) + 1] = bad + r2_server.requests.clear() + with pytest.raises((ValueError, SystemExit)): + artifacts.main(invalid) + assert not r2_server.requests + assert r2_server.store[pointer] == previous + + # Fail a real immutable upload by occupying its name with different bytes. + r2_server.store[f'{directory}/{bundle.name}'] = (b'other bundle', 'application/msixbundle') + r2_server.requests.clear() + with pytest.raises(ValueError, match='checksum mismatch'): + artifacts.main(args) + assert not any(method == 'PUT' and path.endswith('canary.appinstaller') + for method, path, _ in r2_server.requests) + assert r2_server.store[pointer] == previous + + +@pytest.mark.parametrize('channel', ['stable', 'canary', 'light/stable', 'light/canary']) +def test_descriptor_cli_preserves_package_facts_and_subscription_uri(tmp_path, channel): + identity, publisher = 'Product&<"test">', 'CN=Publisher & "Team"' + self_uri = f'https://releases.example/releases/win32/{channel}/update.appinstaller?a=1&b=2' + out = tmp_path / 'descriptor.appinstaller' + for version in ['1.2.3.0', '1.2.4.31']: + artifact_uri = f'https://releases.example/releases/tag/v{version}/app.msixbundle?a=1&b=2' + subprocess.run([ + sys.executable, '-m', 'scripts.bundles.release_artifacts', 'appinstaller', + '--root', str(tmp_path), '--out', str(out), '--identity', identity, + '--publisher', publisher, '--version', version, + '--self-uri', self_uri, '--artifact-uri', artifact_uri, + ], cwd=Path(__file__).resolve().parents[2], check=True) + descriptor = ET.parse(out).getroot() + assert descriptor.tag == '{http://schemas.microsoft.com/appx/appinstaller/2017/2}AppInstaller' + assert descriptor.attrib == {'Uri': self_uri, 'Version': version} + assert descriptor.find('{*}MainBundle').attrib == { + 'Name': identity, 'Publisher': publisher, 'Version': version, 'Uri': artifact_uri, + } + assert descriptor.find('{*}UpdateSettings/{*}OnLaunch').attrib == {'HoursBetweenUpdateChecks': '12'} + assert descriptor.find('{*}MainPackage') is None diff --git a/tests/scripts/test_release_promotion.py b/tests/scripts/test_release_promotion.py index 8d51688c05..38005c50dd 100644 --- a/tests/scripts/test_release_promotion.py +++ b/tests/scripts/test_release_promotion.py @@ -2,6 +2,7 @@ import hashlib import io import json +import xml.etree.ElementTree as ET from pathlib import Path import pytest @@ -57,7 +58,13 @@ def test_publish_and_promote_use_the_same_content_before_any_channel_write(tmp_p assert events[0] == 'mac-feed' assert events[-1] == 'releases/termux/stable/dists/hermes-stable/InRelease' assert 'releases/win32/stable/stable.appinstaller' in r2_server.store - assert b'/releases/tag/v1.2.3/app.msixbundle' in r2_server.store['releases/win32/stable/stable.appinstaller'][0] + descriptor = ET.fromstring(r2_server.store['releases/win32/stable/stable.appinstaller'][0]) + windows = next(row for row in packages if row['platform'] == 'windows') + assert descriptor.attrib == {'Uri': f'{base}/releases/win32/stable/stable.appinstaller', 'Version': windows['version']} + assert descriptor.find('{*}MainBundle').attrib == { + 'Name': windows['identity'], 'Publisher': windows['publisher'], + 'Version': windows['version'], 'Uri': windows['artifact']['url'], + } events.clear() content['app.msixbundle'] = b'changed artifact' From 1686e54d4f5bb204ba3963a15f828fab13916bf8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:58:16 -0400 Subject: [PATCH 07/33] refactor(boot): converge runtime checks and launcher maintenance --- gateway/run.py | 32 +- hermes_cli/_launchers.py | 193 ++++++++++- hermes_cli/boot_bootstrap.py | 293 ++-------------- hermes_cli/main.py | 34 +- hermes_cli/post_update.py | 259 +-------------- hermes_cli/venv_sync.py | 23 +- pm/__init__.py | 2 + pm/ensure.py | 21 +- tests/hermes_cli/test_boot_bootstrap.py | 313 ++---------------- tests/hermes_cli/test_boot_convergence.py | 191 +++++++++++ tests/hermes_cli/test_boot_wiring.py | 9 +- tests/hermes_cli/test_post_update.py | 30 +- .../hermes_cli/test_post_update_expose_cli.py | 111 +++++-- .../test_source_launcher_publication.py | 67 ++++ 14 files changed, 669 insertions(+), 909 deletions(-) create mode 100644 tests/hermes_cli/test_boot_convergence.py diff --git a/gateway/run.py b/gateway/run.py index 35ab82ca1a..41b9fbae98 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1503,28 +1503,6 @@ os.environ["_HERMES_GATEWAY"] = "1" _ensure_ssl_certs() -# pm startup: same contract as the CLI dispatch path (hermes_cli/main.py) -# — the gateway daemon never passes through the CLI fast-launch checks, so -# the store's tools (git/bash/ffmpeg/...) must be on PATH here. O(1) stamp -# checks, no network, no installs; warns, never blocks. Runs from main(), -# not import time: importers of this module (relay runtime, platform -# actions, enrollment) need helpers, not PATH provisioning or a pm verdict -# on their behalf. -def _run_pm_startup() -> None: - try: - import pm - - pm.adopt() - problems = pm.check() - if problems: - logging.getLogger("gateway.run").warning( - f"install out of sync ({'; '.join(problems)}) — run `hermes pm install`" - ) - else: - pm.activate() - except Exception: - logging.getLogger("gateway.run").debug("pm startup check failed", exc_info=True) - sys.path.insert(0, str(Path(__file__).parent.parent)) from hermes_constants import get_hermes_home, get_hermes_home_override @@ -5400,7 +5378,15 @@ def main(): # the post-update bootstrap: the same one-pass record-gated maintenance # registry the CLI dispatch path runs (hermes_cli/main.py) — this # entrypoint bypasses that dispatch, so run it here too. Never raises. - _best_effort(_run_pm_startup) + try: + from hermes_cli.boot_bootstrap import default_project_root + from hermes_cli.venv_sync import check_runtime + + problem = check_runtime(default_project_root()) + if problem: + logger.warning(problem) + except Exception: + logger.debug("pm startup check failed", exc_info=True) try: from hermes_cli.boot_bootstrap import ( default_project_root, diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 7c8d081626..3113106456 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -247,14 +247,66 @@ def _launcher_script(name: str, repo_root: Path, dependencies: Path | None) -> s ) -def _mint_shell_launcher(name: str, out_dir: Path, python_exe: Path, script: str) -> Path | None: - command = shlex.join([str(python_exe), "-I", "-c", script]) +def _write_shell(target: Path, command: list[str]) -> Path | None: + body = f'#!/bin/sh\nexec {shlex.join(command)} "$@"\n' + try: + if not target.is_symlink() and target.read_text(encoding="utf-8") == body: + if os.access(target, os.X_OK): + return target + except (OSError, UnicodeError): + pass def write(staging: Path) -> None: - staging.write_text(f'#!/bin/sh\nexec {command} "$@"\n', encoding="utf-8", newline="\n") + staging.write_text(body, encoding="utf-8", newline="\n") staging.chmod(0o755) - return _write_atomic(out_dir / name, write) + return _write_atomic(target, write) + + +def _mint_shell_launcher(name: str, out_dir: Path, python_exe: Path, script: str) -> Path | None: + return _write_shell(out_dir / name, [str(python_exe), "-I", "-c", script]) + + +def _owns_launcher(target: Path, root: Path) -> bool: + """Recognize our old source/venv launchers, never a mere mention in a comment.""" + if target.is_symlink(): + return target.resolve().is_relative_to(root) + try: + tokens = shlex.split(target.read_text(encoding="utf-8-sig"), comments=True) + except (OSError, UnicodeError, ValueError): + return False + paths = {str(root / p) for p in ( + "hermes", "run_agent.py", "venv/bin/python", "venv/bin/python3", + ".hermes/bin/hermes", ".hermes/bin/hermes-acp", + )} + # Current store launchers pass this Python bootstrap as one shell argument. + bootstrap = f"sys.path.insert(0, {str(root)!r})" + if paths.intersection(tokens) or any(bootstrap in token for token in tokens): + return True + # The historical updater wrote ACP as a sibling-hermes forwarder. Adopt + # it only when that sibling demonstrably belongs to this installation. + if target.name == "hermes-acp": + sibling = target.with_name("hermes") + return (tokens == ["exec", str(sibling), "acp", "$@"] + and _owns_launcher(sibling, root)) + return False + + +def _publish_conveniences(root: Path, out_dir: Path, names) -> dict[Path, bool]: + """User-bin commands forward to durable local launchers, not a Python pin.""" + out_dir.mkdir(parents=True, exist_ok=True) + published = {} + for name in names: + target = out_dir / name + if (target.exists() or target.is_symlink()) and not _owns_launcher(target, root): + continue + before = target.lstat().st_mtime_ns if target.exists() or target.is_symlink() else None + command = ([str(root / ".hermes/bin/hermes"), "--run-module", "run_agent"] + if name == "hermes-agent" else [str(root / ".hermes/bin" / name)]) + if _write_shell(target, command) is None: + raise OSError(f"could not publish launcher {target}") + published[target] = before != target.lstat().st_mtime_ns + return published def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: @@ -275,23 +327,128 @@ def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: def ensure_install_launchers(repo_root: Path, out_dir: Path) -> list[str]: - """Publish exact-install commands and their user-bin conveniences. - - Installers/updaters use the local command, since a shared HOME/bin may - have been repointed to another checkout. Return the requested outputs. - """ - repo_root = Path(repo_root) - local = repo_root / ".hermes" / "bin" + """Publish exact-install commands; conveniences follow them across Python repins.""" + root = Path(repo_root).resolve() + local = root / ".hermes" / "bin" local.mkdir(parents=True, exist_ok=True) + written = [str(path) for name in WINDOWS_BIN_LAUNCHERS + if (path := stage_launcher(name, root, local)) is not None] + if Path(out_dir).resolve() == local: + return written + if len(written) != len(WINDOWS_BIN_LAUNCHERS): + return [] + if not _is_windows(): + return [str(path) for path in _publish_conveniences(root, Path(out_dir), WINDOWS_BIN_LAUNCHERS)] + Path(out_dir).mkdir(parents=True, exist_ok=True) + return [str(path) for name in WINDOWS_BIN_LAUNCHERS + if (path := stage_launcher(name, root, Path(out_dir))) is not None] + + +def expose_cli(project_root: Path | None = None) -> dict: + """Repair PATH conveniences without taking over another installation's files. + + macOS CLI-first launches link the bundle's signed shims directly; Electron + need not have run. Source installs converge on the store launcher owner. + Shell rc/PATH registration remains installer-owned. + """ + if _is_windows(): + return {"ok": True, "skipped": "windows-installer-owned"} + try: + from hermes_cli.config import load_config + except ImportError: + # The explicit PM bootstrap publishes Python before application deps. + # Installers will expose it after sync; never invent a config reader here. + return {"ok": True, "skipped": "config-unavailable"} + from hermes_cli.steward import read_install_stamp + + cli_cfg = (load_config() or {}).get("cli", {}) + if isinstance(cli_cfg, dict) and not cli_cfg.get("expose_on_path", True): + return {"ok": True, "skipped": "config-disabled"} + root = Path(project_root or os.environ.get("HERMES_INSTALL_ROOT") or Path(__file__).resolve().parents[1]).resolve() + if _is_bundled_payload(root): + if sys.platform == "darwin": + return _symlink_sealed_launchers(root.parent / "bin") + return {"ok": True, "skipped": "bundle-owns-launchers"} + if read_install_stamp(root).get("updateMechanism") == "external": + return {"ok": True, "skipped": "externally-owned"} + if resolve_store_python(root) is None: + return {"ok": True, "skipped": "no-store-python"} + try: + local = root / ".hermes" / "bin" + if len(ensure_install_launchers(root, local)) != len(WINDOWS_BIN_LAUNCHERS): + return {"ok": False, "error": "source launcher publication failed"} + dirs = [Path.home() / ".local" / "bin"] + # Repair existing FHS/custom-home exposure, but never create new global + # entries or reclaim a convenience that was repointed to another root. + from hermes_constants import get_default_hermes_root + for directory in (get_default_hermes_root() / "bin", Path("/usr/local/bin")): + if directory not in dirs and _owns_launcher(directory / "hermes", root): + dirs.append(directory) + written = [] + for directory in dirs: + published = _publish_conveniences(root, directory, (*WINDOWS_BIN_LAUNCHERS, "hermes-agent")) + written.extend(path.name for path, changed in published.items() if changed) + return {"ok": True, "written": written} + except OSError as exc: + return {"ok": False, "error": str(exc)} + + +def _is_bundled_payload(root: Path) -> bool: + """Is ``root`` a desktop bundle's agent payload? The stamp is the + authority (payload marker / desktop-app distribution), never a + sibling-directory sniff; a .git tree is a dev checkout regardless.""" + if (root / ".git").exists(): + return False + from hermes_cli.steward import STEWARD_DESKTOP, read_install_stamp + + stamp = read_install_stamp(root) + if not stamp: + return False + return bool(stamp.get("payload")) or stamp.get("distribution") == STEWARD_DESKTOP + + +def _symlink_sealed_launchers(payload_bin) -> dict: + """Link ~/.local/bin/{hermes,hermes-agent,hermes-acp} at a sealed + bundle's own prebuilt shims (macOS only). + + Symlinks, not copies: the shims are signed as part of the app bundle, + and a copy would both orphan the signature's context and go stale on + every app update — a symlink into the .app follows the bundle's + content wherever Squirrel.Mac swaps it. + + Ownership guard mirrors expose_cli's wrapper logic: an existing + entry is replaced only when it is ours — a symlink into THIS app + bundle's payload — or missing/broken. A user's own `hermes` (pipx, + another checkout's wrapper) is never touched. + """ + link_dir = Path.home() / ".local" / "bin" + payload_root = payload_bin.parent written: list[str] = [] - for name in WINDOWS_BIN_LAUNCHERS: - if Path(out_dir).resolve() != local.resolve(): - if stage_launcher(name, repo_root, local) is None: + try: + link_dir.mkdir(parents=True, exist_ok=True) + for name in ("hermes", "hermes-agent", "hermes-acp"): + source = payload_bin / name + if not source.is_file(): continue - path = stage_launcher(name, repo_root, Path(out_dir)) - if path is not None: - written.append(str(path)) - return written + target = link_dir / name + if target.is_symlink(): + current = os.readlink(target) + if current == str(source): + continue # already ours and current + # Ours if it points into this payload (stale app path from + # a previous version counts — resolve() of a dangling link + # still yields the old path text) — or dangling entirely. + points_into_payload = str(Path(current)).startswith(str(payload_root) + os.sep) + if not points_into_payload and target.exists(): + continue # a live foreign link — user's arrangement + elif target.exists(): + continue # a real file we did not write — never clobber + target.unlink(missing_ok=True) + target.symlink_to(source) + written.append(name) + except OSError as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "written": written, "mode": "sealed-symlinks"} if __name__ == "__main__": diff --git a/hermes_cli/boot_bootstrap.py b/hermes_cli/boot_bootstrap.py index 0b0ef9f5ac..4caa8530c6 100644 --- a/hermes_cli/boot_bootstrap.py +++ b/hermes_cli/boot_bootstrap.py @@ -1,44 +1,18 @@ -"""Boot-time post-update bootstrap. +"""Bounded home maintenance after an installed revision changes. -Every install kind (git checkout, desktop bundled payload, docker, nix) -compares two per-install facts at boot: - -* current identity — the commit this install IS: ``install-stamp.json`` - for sealed trees, git HEAD for checkouts. Reading it is a couple of - file reads (plus one rev-parse for checkouts). -* last-known identity — the commit this install last bootstrapped, - recorded under ``installs//bootstrap/`` keyed by the canonical - install root. - -Equal → nothing happens (the fast path, ~2 ms). Different → run the -idempotent post-update steps from ``hermes_cli.post_update`` under a -single-flight lock, then record the new identity. - -Two records, one per step scope: - -* home record — ``bootstrap/.json``. Gates home-scoped steps. - HERMES_HOME moves per profile, so each profile bootstraps its own - state once per code change. -* machine record — ``bootstrap/machine.json``. Every profile resolves - the same file, so machine-global steps run once per machine per code - change and the record's lock serializes concurrent profile boots. - -The records are an optimization, never the correctness layer: every step -is idempotent and self-gating, so a deleted record costs one redundant -slow path, nothing more. - -Ported from the restack branch's boot_bootstrap, rewritten onto this -branch's vocabulary: stamps via ``hermes_cli.steward``, managed tools via -``pm`` (facts.json ledger) instead of the retired ``installation`` package. +A per-install, per-profile record and lock remain because config migrations +must not retry a broken migration on every launch, and the full SQLite +integrity guard is too expensive to run every time. Steps still own their +idempotence and rollback; failure is recorded until the next revision (or +an explicit post-update run). PM owns runtime diagnosis, not this record. """ from __future__ import annotations -from hermes_cli.runtime_paths import install_state_dir, installs_root +from hermes_cli.runtime_paths import install_state_dir import json import logging import os import subprocess -import sys import time from pathlib import Path @@ -137,147 +111,12 @@ def current_install_identity(project_root: Path) -> str | None: # the per-install state folder: installs// under the DEFAULT home # --------------------------------------------------------------------------- -def ensure_install_dir(project_root: Path) -> Path: - """The state folder, created with its identity record on first touch. +def record_path(project_root: Path) -> Path: + """Each profile completes its own home maintenance for this installation.""" + from hermes_cli.profiles import get_active_profile_name - install.json is the REVERSE map (sha16 → canonical root) that makes - orphan GC possible: `hermes doctor` enumerates installs/*/install.json - and flags entries whose recorded root no longer exists. Written once, - under the same single-flight lock the records use; the steward comes - from hermes_cli.steward so the record says who owns the tree, not who - touched it first. - """ - state = install_state_dir(project_root) - marker = state / "install.json" - if marker.is_file(): - return state - state.mkdir(parents=True, exist_ok=True) - lock = _RecordLock(state / ".install-json.lock") - if not lock.acquire(): - return state # someone else is writing it right now — theirs wins - try: - if not marker.is_file(): - from datetime import datetime, timezone - - from hermes_cli.steward import sealed_steward - - steward = sealed_steward(Path(project_root)) - payload = { - "root": str(Path(project_root).resolve()), - "steward": steward if steward is not None else "checkout", - "firstSeen": datetime.now(timezone.utc).isoformat(), - } - tmp = marker.with_suffix(".json.tmp") - tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, marker) - finally: - lock.release() - return state - - -def orphaned_installs() -> list[tuple[Path, str]]: - """State folders whose recorded root no longer exists. - - ``(folder, recorded_root)`` pairs for `hermes doctor`'s sweep. A - folder without a readable install.json is orphaned by definition — - nothing can ever claim it again, because claiming goes through - ensure_install_dir which writes the record first. - """ - root = installs_root() - if not root.is_dir(): - return [] - orphans: list[tuple[Path, str]] = [] - for entry in sorted(root.iterdir()): - if not entry.is_dir(): - continue - try: - recorded = json.loads( - (entry / "install.json").read_text(encoding="utf-8-sig") - ).get("root", "") - except (OSError, ValueError): - orphans.append((entry, "")) - continue - if not recorded or not Path(recorded).exists(): - orphans.append((entry, recorded or "")) - return orphans - - -def orphaned_store_entries() -> list[tuple[Path, int]]: - """Tool-store entries the installed-state ledger no longer references. - - ``(entry_dir, size_bytes)`` pairs for `hermes doctor`'s sweep. pm's - facts.json is the only authority consulted — the same ledger - ``pm.ensure`` resolves by, so this can never flag an entry pm would - still hand out. An entry is REFERENCED when facts records it (tool - entries) or when it is a ``fetch-*`` archive cache entry backing a - referenced install. Everything else is bytes no lookup can ever - return: superseded versions left behind by pin bumps. - - Doubt errs toward KEEP: a facts file that exists but cannot be read - aborts the whole sweep (empty result), because its references are - unknowable and any entry might be one of them. No facts file at all - means pm never installed anything — nothing is referenced, but there - is also nothing to GC against, so the sweep reports nothing. - """ - from pm import paths as pm_paths - from pm.lock import Facts - - store = pm_paths.store_root() - if not store.is_dir(): - return [] - facts_file = pm_paths.facts_path() - if not facts_file.is_file(): - return [] - try: - raw = json.loads(facts_file.read_text(encoding="utf-8-sig")) - if not isinstance(raw, dict): - return [] - except (OSError, ValueError): - # Unreadable ledger: references unknowable — keep everything. - return [] - referenced = Facts(facts_file).entries_in_use() - - orphans: list[tuple[Path, int]] = [] - for entry in sorted(store.iterdir()): - # Scratch dirs (.staging-*), the ledger itself, and stray files - # are pm's own cleanup problem, never GC candidates. fetch-* - # archive cache entries are content-addressed and cheap to keep; - # skip them too (re-fetch avoidance is their whole point). - if not entry.is_dir() or entry.name.startswith("."): - continue - if entry.name.startswith("fetch-"): - continue - if entry.name in referenced: - continue - size = 0 - for f in entry.rglob("*"): - try: - if f.is_file() and not f.is_symlink(): - size += f.stat().st_size - except OSError: - continue - orphans.append((entry, size)) - return orphans - - -def record_path(project_root: Path, scope: str) -> Path: - """Where the last-known record for ``project_root`` lives. - - Both scopes live INSIDE the per-install state folder: - ``bootstrap/machine.json`` for machine scope, ``bootstrap/.json`` - for home scope — the per-profile semantics ride the FILENAME, not a - per-profile anchor directory. - """ - if scope == "home": - from hermes_cli.profiles import get_active_profile_name - - name = get_active_profile_name() or "default" - filename = f"{name}.json" - elif scope == "machine": - filename = "machine.json" - else: - raise ValueError(f"unknown record scope: {scope!r}") - return install_state_dir(project_root) / "bootstrap" / filename + name = get_active_profile_name() or "default" + return install_state_dir(project_root) / "bootstrap" / f"{name}.json" def read_last_known(path: Path) -> dict: @@ -301,19 +140,13 @@ def _write_record(path: Path, identity: str, results: dict) -> None: os.replace(tmp, path) -def write_record(project_root: Path, scope: str, identity: str, results: dict | None = None) -> None: - """Record ``identity`` as bootstrapped. Also used by the update phase - after it runs the steps itself, so the next boot skips.""" - _write_record(record_path(project_root, scope), identity, results or {}) - - -def needs_bootstrap(project_root: Path, scope: str) -> str | None: +def needs_bootstrap(project_root: Path) -> str | None: """The new identity when this install changed since its last bootstrap, else None. None identity (broken tree) never bootstraps.""" identity = current_install_identity(project_root) if not identity: return None - known = read_last_known(record_path(project_root, scope)) + known = read_last_known(record_path(project_root)) if known.get("identity") == identity: return None return identity @@ -391,89 +224,27 @@ class _RecordLock: # the boot entry point # --------------------------------------------------------------------------- -def _report_sealed_runtime_drift(project_root: Path) -> str | None: - """Check a SEALED tree's managed tools against pm's lockfile, loudly. - - pm's artifact-time gates (bundle staging, docker build, nix check) - are the wall; this is the boot-time backstop for artifacts assembled - around them: every boot of a drifted sealed tree prints the problem - list to stderr, so the drift is impossible to not-know about. - - Report, not refusal: this runs inside the never-raises boot path, and - a sealed gateway that boots on stale tools is degraded — but a - gateway that refuses to boot over a tool version is DOWN, remotely, - with the fix (rebuild the artifact) out of the machine's own reach. - - Returns the message when drift was found (for the boot summary), None - otherwise. Checkouts return None without reading anything — they - provision on demand and drift is their normal, self-healing state. - """ - root = Path(project_root) - if (root / ".git").exists(): - return None - try: - from hermes_cli.steward import sealed_steward - - steward = sealed_steward(root) - if steward is None: - return None - import pm - - problems = pm.check() - except Exception as exc: # noqa: BLE001 — a backstop must not become a gate - logger.debug("sealed runtime drift check failed: %s", exc) - return None - if not problems: - return None - message = ( - f"this {steward}-managed install's tools drifted from its pin table: " - + "; ".join(problems) - + " — rebuild the artifact to fix" - ) - print(f"\n✗ {message}\n", file=sys.stderr) - return message - - def run_boot_bootstrap(project_root: Path) -> dict: - """Run due home- and machine-scoped steps for this install. Returns a - summary dict (for tests/logs); use maybe_run_boot_bootstrap at call - sites.""" + """Bound home maintenance to one attempt per installed revision.""" from hermes_cli import post_update - summary: dict = {"home": "skipped", "machine": "skipped"} - - drift_message = _report_sealed_runtime_drift(Path(project_root)) - if drift_message: - summary["sealed_runtime_drift"] = drift_message - - for scope, steps in ( - ("home", post_update.BOOT_HOME_STEPS), - ("machine", post_update.BOOT_MACHINE_STEPS), - ): - identity = needs_bootstrap(project_root, scope) - if not identity: - continue - record = record_path(project_root, scope) - lock = _RecordLock(record) - if not lock.acquire(): - summary[scope] = "lost-race" - continue - try: - # Double-check under the lock: the previous holder may have - # finished between our read and our acquire. - if read_last_known(record).get("identity") == identity: - summary[scope] = "done-by-other" - continue - logger.info( - "post-update bootstrap (%s scope): code changed to %s, running steps", - scope, identity[:12], - ) - results = post_update.run_steps(steps) - _write_record(record, identity, results) - summary[scope] = results - finally: - lock.release() - return summary + identity = needs_bootstrap(project_root) + if not identity: + return {"home": "skipped"} + record = record_path(project_root) + lock = _RecordLock(record) + if not lock.acquire(): + return {"home": "lost-race"} + try: + # A previous holder may have finished after our first read. + if read_last_known(record).get("identity") == identity: + return {"home": "done-by-other"} + logger.info("home maintenance: code changed to %s, running steps", identity[:12]) + results = post_update.run_steps(post_update.BOOT_HOME_STEPS) + _write_record(record, identity, results) + return {"home": results} + finally: + lock.release() def maybe_run_boot_bootstrap(project_root: Path) -> None: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a2ea0df684..589aa6ad2c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3397,6 +3397,19 @@ def main(): from hermes_cli.boot_bootstrap import default_project_root, maybe_run_boot_bootstrap maybe_run_boot_bootstrap(default_project_root()) + # Every dispatch, including fast chat/serve, gets one passive PM verdict. + try: + from hermes_cli.boot_bootstrap import default_project_root + from hermes_cli.venv_sync import check_runtime + + problem = check_runtime(default_project_root()) + if problem: + print(f"⚠ {problem}", file=sys.stderr) + except Exception: + import logging + + logging.getLogger(__name__).debug("pm startup check failed", exc_info=True) + if _try_termux_fast_tui_launch(): return if _try_termux_fast_cli_launch(): @@ -3406,27 +3419,6 @@ def main(): if _try_fast_chat_launch(): return - # The startup check: O(1) stamp comparisons, no network, no installs. - # One loud line when the install is damaged; never blocks the command. - # Then provision: prepend the store's tool dirs to PATH so reactive - # which('git'|'bash'|'ffmpeg'|...) resolves the bundled binaries. - try: - import pm - - pm.adopt() - problems = pm.check() - if problems: - print( - f"⚠ install out of sync ({'; '.join(problems)}) — run `hermes pm install`", - file=sys.stderr, - ) - else: - pm.activate() - except Exception: - import logging - - logging.getLogger(__name__).debug("pm startup check failed", exc_info=True) - parser, subparsers = _build_cli_parser() # NixOS container mode routes ALL invocations into the managed container. diff --git a/hermes_cli/post_update.py b/hermes_cli/post_update.py index 3eac173365..776cacf9e2 100644 --- a/hermes_cli/post_update.py +++ b/hermes_cli/post_update.py @@ -1,29 +1,21 @@ -"""Post-update maintenance steps shared by ``hermes update`` and boot bootstrap. +"""Home maintenance shared by explicit updates and bounded boot maintenance. -Each step operates on user state (config.yaml, skills, state.db) or machine -state (managed tools), never on the install tree. Every step is idempotent -and self-gating: running it twice, or from two installs that share one -HERMES_HOME, converges. The caller (boot_bootstrap, update_cmd) decides WHEN -steps run; this module owns WHAT they do. - -Steps declare a scope: - -* ``home`` — mutates the active HERMES_HOME (per profile). -* ``machine`` — machine-global state shared by every profile. - -The scopes must match the record that gates them in ``boot_bootstrap`` -(home record vs machine record). +Config migration owns backup/rollback; skills sync owns content merging; the +SQLite guard detects damage without repairing it. Boot bounds these home +steps per revision. The explicit scope CLI also provisions runtimes through +PM. Launcher publication belongs to hermes_cli._launchers. """ from __future__ import annotations import logging import os import shutil -import sys from datetime import datetime, timezone from pathlib import Path from typing import Iterable +from hermes_cli._launchers import expose_cli + logger = logging.getLogger(__name__) @@ -245,38 +237,27 @@ def step_adopt_blessed_checkout(project_root: Path | None = None) -> dict: def step_provision_runtimes() -> dict: - """Refresh managed tools (node, uv, git, gh, ripgrep, the venv) against - pm's lockfile after a code change. + """Explicit refresh of PM's drifted packages; never selected by boot. - ``pm.check()`` is the cheap verdict: stamp comparisons of the - installed-state ledger against pm/lock.json, no network. Anything it - names is re-ensured through the same ``pm.ensure``/``pm.sync_venv`` - calls every other install path uses — pm stays the single authority - on tool versions (a pin bump rides in exactly like node or ripgrep; - cua-driver gets no bespoke refresh step for the same reason). - - ``explicit=True`` because reaching this step IS the deliberate remedy - (the code just changed under this install — the same trust `hermes - update` carries); the lazy-install policy still gates the whole sweep - so a policy-locked machine skips instead of installing. + PM supplies package identities, not diagnostic text to parse. Its own + ensure/sync operations own freshness checks and publication under lock. """ import pm from pm.ensure import lazy_installs_allowed, sealed - problems = pm.check() + problems = pm.drift() if not problems: return {"ok": True, "skipped": "current"} if sealed(): # A sealed payload's tools ship with the artifact; drift here is - # a packaging bug boot_bootstrap reports, not something to fix. + # an artifact build problem, not something to repair in place. return {"ok": True, "skipped": "sealed"} if not lazy_installs_allowed(): return {"ok": True, "skipped": "lazy-installs-disabled"} refreshed: list[str] = [] errors: list[str] = [] - for problem in problems: - name = problem.split(":", 1)[0].strip() + for name in problems: try: if name == "venv": pm.sync_venv(explicit=True) @@ -291,206 +272,6 @@ def step_provision_runtimes() -> dict: return {"ok": True, "refreshed": refreshed} -def step_report_runtime_drift() -> dict: - """CHECK-ONLY machine step for automatic boot. - - ``pm.check()`` is O(1) stamp comparisons against the installed-state - ledger — no network, no installs. Drift is reported loudly (the same - verdict the CLI startup block prints) so the user knows to run - ``hermes pm install``; boot itself never installs anything. The - installing pass is MACHINE_STEPS below, selected by the scope CLI. - Automatic boot must not run network installers. - """ - import pm - - problems = pm.check() - if not problems: - return {"ok": True, "skipped": "current"} - logger.warning( - "install out of sync (%s) — run `hermes pm install`", "; ".join(problems) - ) - return {"ok": True, "drift": problems} - - -def step_expose_cli() -> dict: - """Keep the user-facing ``hermes`` launchers alive across updates. - - The installers write POSIX wrapper scripts into the link dir - (``~/.local/bin`` and friends) exactly once, at install time — so a - moved checkout, a recreated venv, or a user's stray ``rm`` leaves - stale or missing launchers that nothing repairs until a full - reinstall. This step makes the POST-UPDATE side own the recurring - maintenance: rewrite the three wrappers (hermes, hermes-agent, - hermes-acp) whenever their recorded shape drifts from what this - tree would write today. First-time PATH bootstrapping (shell-rc - edits, Windows registry) stays installer-side on purpose — a - boot-time step must not edit rc files on every update. - - Config-gated by ``cli.expose_on_path`` (default true). Windows is a - no-op: venv Scripts are already User-PATH-persisted by the installer. - """ - if sys.platform == "win32": - return {"ok": True, "skipped": "windows-installer-owned"} - - try: - from hermes_cli.config import load_config - - cli_cfg = (load_config() or {}).get("cli", {}) - if isinstance(cli_cfg, dict) and not bool(cli_cfg.get("expose_on_path", True)): - return {"ok": True, "skipped": "config-disabled"} - except Exception as exc: # noqa: BLE001 — config trouble must not kill the step - logger.debug("Could not read cli.expose_on_path: %s", exc) - - root = _install_root() - - # Shape FIRST, capability second. The stamp is the shape authority: a - # bundled desktop payload never gets installer-written wrappers — its - # launchers SHIP in agent-payload/bin as prebuilt signed shims. macOS - # is the one platform where nothing at install time can expose them - # (a dragged .app runs no installer), so there — and only there — - # link the user's bin dir at the bundle's own shims. A Linux AppImage - # mounts at a transient path a symlink to which would dangle the - # moment the app exits. - if _is_bundled_payload(root): - if sys.platform == "darwin": - return _symlink_sealed_launchers(root.parent / "bin") - return {"ok": True, "skipped": "bundle-owns-launchers"} - - # pm-store-managed install: the launchers are owned by - # hermes_cli._launchers (staged by install.ps1 / _install_repair — - # boot the STORE python with a repo-first PYTHONPATH, never - # venv/Scripts|bin python). This step's venv-wrapper shape is NOT - # that shape; writing it here would rewrite a store launcher into a - # venv boot. No alternate writer: the existing owner maintains - # these (per-name, at install and at process-start repair). - try: - from hermes_cli._launchers import resolve_store_python - - if resolve_store_python(root) is not None: - return {"ok": True, "skipped": "store-launchers-owned"} - except Exception as exc: # noqa: BLE001 — never kill boot over a probe - logger.debug("store-python probe failed: %s", exc) - - # Capability probe for the checkout shape: the wrapper bodies bake - # these two paths into text, so both must exist to have anything to - # point at. A checkout with a nuked venv lands here — skip; the - # installer/bootstrap owns venv repair, not this step. - venv_python = root / "venv" / "bin" / "python" - entrypoint = root / "hermes" - if not venv_python.is_file() or not entrypoint.is_file(): - return {"ok": True, "skipped": "no-venv-layout"} - - link_dir = Path.home() / ".local" / "bin" - wrappers = { - "hermes": f'exec "{venv_python}" "{entrypoint}" "$@"', - "hermes-agent": f'exec "{venv_python}" "{root / "run_agent.py"}" "$@"', - "hermes-acp": f'exec "{venv_python}" "{entrypoint}" acp "$@"', - } - - written: list[str] = [] - try: - link_dir.mkdir(parents=True, exist_ok=True) - for name, exec_line in wrappers.items(): - target = link_dir / name - body = ( - "#!/usr/bin/env bash\n" - "unset PYTHONPATH\n" - "unset PYTHONHOME\n" - f"{exec_line}\n" - ) - try: - existing = target.read_text(encoding="utf-8-sig") - except (FileNotFoundError, UnicodeDecodeError, OSError): - existing = None - if existing == body: - continue # current — do not churn mtimes every boot - # A launcher pointing at ANOTHER install is the user's own - # arrangement (two checkouts, one link dir) — leave it alone. - # Ours means: mentions this root in its text, OR is a symlink - # resolving into this root (the pre-#21454 install shape, - # where the link dir pointed straight at the venv console - # script — reading THROUGH it shows no path at all). - is_symlink_into_root = ( - target.is_symlink() - and str(target.resolve()).startswith(str(root) + os.sep) - ) - if ( - existing is not None - and existing.strip() - and str(root) not in existing - and not is_symlink_into_root - ): - continue - # The installers' #21454 lesson: clear first, so writing can - # never follow an old symlink into the venv and clobber a - # console script. - target.unlink(missing_ok=True) - target.write_text(body, encoding="utf-8") - target.chmod(0o755) - written.append(name) - except OSError as exc: - return {"ok": False, "error": str(exc)} - return {"ok": True, "written": written} - - -def _is_bundled_payload(root: Path) -> bool: - """Is ``root`` a desktop bundle's agent payload? The stamp is the - authority (payload marker / desktop-app distribution), never a - sibling-directory sniff; a .git tree is a dev checkout regardless.""" - if (root / ".git").exists(): - return False - from hermes_cli.steward import STEWARD_DESKTOP, read_install_stamp - - stamp = read_install_stamp(root) - if not stamp: - return False - return bool(stamp.get("payload")) or stamp.get("distribution") == STEWARD_DESKTOP - - -def _symlink_sealed_launchers(payload_bin) -> dict: - """Link ~/.local/bin/{hermes,hermes-agent,hermes-acp} at a sealed - bundle's own prebuilt shims (macOS only). - - Symlinks, not copies: the shims are signed as part of the app bundle, - and a copy would both orphan the signature's context and go stale on - every app update — a symlink into the .app follows the bundle's - content wherever Squirrel.Mac swaps it. - - Ownership guard mirrors step_expose_cli's wrapper logic: an existing - entry is replaced only when it is ours — a symlink into THIS app - bundle's payload — or missing/broken. A user's own `hermes` (pipx, - another checkout's wrapper) is never touched. - """ - link_dir = Path.home() / ".local" / "bin" - payload_root = payload_bin.parent - written: list[str] = [] - try: - link_dir.mkdir(parents=True, exist_ok=True) - for name in ("hermes", "hermes-agent", "hermes-acp"): - source = payload_bin / name - if not source.is_file(): - continue - target = link_dir / name - if target.is_symlink(): - current = os.readlink(target) - if current == str(source): - continue # already ours and current - # Ours if it points into this payload (stale app path from - # a previous version counts — resolve() of a dangling link - # still yields the old path text) — or dangling entirely. - points_into_payload = str(Path(current)).startswith(str(payload_root) + os.sep) - if not points_into_payload and target.exists(): - continue # a live foreign link — user's arrangement - elif target.exists(): - continue # a real file we did not write — never clobber - target.unlink(missing_ok=True) - target.symlink_to(source) - written.append(name) - except OSError as exc: - return {"ok": False, "error": str(exc)} - return {"ok": True, "written": written, "mode": "sealed-symlinks"} - - # --------------------------------------------------------------------------- # step registries — boot_bootstrap gates each list with the matching record # --------------------------------------------------------------------------- @@ -500,7 +281,7 @@ HOME_STEPS: tuple = ( ("migrate_config", step_migrate_config), ("sync_skills", step_sync_skills), ("state_db_guard", step_state_db_guard), - ("expose_cli", step_expose_cli), + ("expose_cli", expose_cli), ) # Startup skill syncing belongs to each entry point. Boot bootstrap must @@ -509,21 +290,11 @@ BOOT_HOME_STEPS: tuple = tuple( step for step in HOME_STEPS if step[0] != "sync_skills" ) -# Only the explicit scope CLI selects installing machine steps. Automatic -# boot uses the check-only registry below. +# Only the explicit scope CLI installs runtimes. Boot checks PM at startup. MACHINE_STEPS: tuple = ( ("provision_runtimes", step_provision_runtimes), ) -# What AUTOMATIC boot runs for the machine scope: the same pm.check() -# verdict, reported instead of installed (boot must not block on, or -# trigger, network installs — the user runs `hermes pm install` or the -# explicit update pass when they want the repair). -BOOT_MACHINE_STEPS: tuple = ( - ("report_runtime_drift", step_report_runtime_drift), -) - - def run_steps(steps: Iterable) -> dict: """Run steps in order; one failure never stops the rest. diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index cd312df88a..d9ddb400f6 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -49,9 +49,25 @@ def _is_sealed(project_root: Path) -> bool: return True +def check_runtime(project_root: Path) -> str | None: + """One passive startup verdict; callers only choose stderr or logging.""" + import pm + from hermes_cli.steward import sealed_steward + + pm.adopt() + problems = pm.check() + if not problems: + pm.activate() + return None + steward = sealed_steward(Path(project_root)) + remedy = (f"this {steward}-managed install must rebuild the artifact to fix" + if steward else "run `hermes pm install`") + return f"install out of sync ({'; '.join(problems)}) — {remedy}" + + def publish_launchers(project_root: Path) -> None: """Refresh the durable source command before an old Python can be collected.""" - from hermes_cli._launchers import ENTRY_POINTS, ensure_install_launchers, resolve_store_python + from hermes_cli._launchers import ENTRY_POINTS, ensure_install_launchers, expose_cli, resolve_store_python from hermes_cli.steward import read_install_stamp root = Path(project_root) @@ -63,6 +79,11 @@ def publish_launchers(project_root: Path) -> None: from pm.package import InstallError raise InstallError("launchers", "source launcher publication failed", "retry the source update") + result = expose_cli(root) + if not result["ok"]: + import logging + + logging.getLogger(__name__).warning("CLI exposure failed: %s", result["error"]) def sync(project_root: Path | None = None, *, check: bool = False) -> dict: diff --git a/pm/__init__.py b/pm/__init__.py index 801d1e997b..660c430097 100644 --- a/pm/__init__.py +++ b/pm/__init__.py @@ -15,6 +15,7 @@ from pm.ensure import ( activate, adopt, check, + drift, enabled_extras, env_for, is_installed, @@ -40,6 +41,7 @@ __all__ = [ "installed_package", "adopt", "check", + "drift", "activate", "sync_venv", "available", diff --git a/pm/ensure.py b/pm/ensure.py index 4a1c17516c..c84b9daf13 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -641,17 +641,17 @@ def adopt() -> bool: return True -def check() -> list[str]: - """The startup check: cheap stamp comparisons of the installed state - against the lockfile. Returns problems; empty means healthy. Never +def drift() -> dict[str, str]: + """Cheap stamp comparisons of the installed state + against the lockfile. Maps package names to reasons; empty means healthy. Never installs, never touches the network. An install pm has never touched (no installed-state file) reports nothing — pm only vouches for what it installed. Lockfile packages this build doesn't know (version skew during a partial update) are skipped, not fatal.""" if not paths.facts_path().is_file() and not paths.runtime_facts_path().is_file(): - return [] + return {} - problems: list[str] = [] + problems: dict[str, str] = {} lockfile = _lockfile() facts = _facts() store = _store() @@ -666,7 +666,7 @@ def check() -> list[str]: if package.missing_reason(target) is not None: continue if _installed_location(package, lockfile, target) is None: - problems.append(f"{name}: not installed or outdated") + problems[name] = "not installed or outdated" try: venv = get_package("venv") except KeyError: @@ -674,12 +674,17 @@ def check() -> list[str]: if venv is not None and (paths.runtime_facts_path().is_file() or facts.get("venv") is not None): try: if not venv_is_current(): - problems.append("venv: out of sync with uv.lock") + problems["venv"] = "out of sync with uv.lock" except (OSError, RuntimeError, ValueError) as exc: - problems.append(f"venv: {exc}") + problems["venv"] = str(exc) return problems +def check() -> list[str]: + """Human-readable startup diagnostics. Use drift() for package identities.""" + return [f"{name}: {reason}" for name, reason in drift().items()] + + def _store_path_dirs() -> list[str]: """Composed PATH dirs of all installed (non-internal, on_path) store packages, deps-first, deduped. Includes optional packages that are diff --git a/tests/hermes_cli/test_boot_bootstrap.py b/tests/hermes_cli/test_boot_bootstrap.py index 699939217f..23aaeda439 100644 --- a/tests/hermes_cli/test_boot_bootstrap.py +++ b/tests/hermes_cli/test_boot_bootstrap.py @@ -2,8 +2,7 @@ The record files are an optimization layer over idempotent steps; these tests assert the contracts that keep that safe: identity resolution from -real git trees and stamps, record scoping (per-install AND per-home vs -per-machine), and the lock protocol including the double-check under lock. +real git trees and stamps, record scoping (per-install AND per-home), and the lock protocol including the double-check under lock. """ import json import os @@ -24,7 +23,7 @@ from hermes_cli.boot_bootstrap import ( read_last_known, record_path, run_boot_bootstrap, - write_record, + _write_record, ) @@ -158,8 +157,8 @@ def test_identity_broken_tree_is_none(tmp_path): def test_record_paths_key_on_install_root(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - a = record_path(tmp_path / "install-a", "home") - b = record_path(tmp_path / "install-b", "home") + a = record_path(tmp_path / "install-a") + b = record_path(tmp_path / "install-b") assert a != b # The key is a FOLDER (installs//bootstrap/.json), # not a filename suffix: same grandparent tree, different key dirs. @@ -168,27 +167,19 @@ def test_record_paths_key_on_install_root(tmp_path, monkeypatch): assert a.name == b.name # the profile filename is the shared part -def test_home_records_differ_per_profile_machine_record_shared(tmp_path, monkeypatch): +def test_home_records_differ_per_profile(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) base = tmp_path / ".hermes" profile = base / "profiles" / "coder" install = tmp_path / "install" monkeypatch.setenv("HERMES_HOME", str(base)) - home_default = record_path(install, "home") - machine_default = record_path(install, "machine") + home_default = record_path(install) monkeypatch.setenv("HERMES_HOME", str(profile)) - home_profile = record_path(install, "home") - machine_profile = record_path(install, "machine") + home_profile = record_path(install) assert home_default != home_profile # each profile bootstraps its own home - assert machine_default == machine_profile # machine record is shared - - -def test_record_path_rejects_unknown_scope(tmp_path): - with pytest.raises(ValueError): - record_path(tmp_path, "galaxy") @pytest.mark.skipif( @@ -200,7 +191,7 @@ def test_symlinked_root_canonicalizes(tmp_path, monkeypatch): real.mkdir() link = tmp_path / "link-install" link.symlink_to(real) - assert record_path(real, "home") == record_path(link, "home") + assert record_path(real) == record_path(link) # ── needs_bootstrap ────────────────────────────────────────────────── @@ -211,21 +202,21 @@ def test_needs_bootstrap_lifecycle(repo, tmp_path, monkeypatch): sha = _head_sha(repo) # No record yet → identity returned. - assert needs_bootstrap(repo, "home") == sha + assert needs_bootstrap(repo) == sha - write_record(repo, "home", sha) - assert needs_bootstrap(repo, "home") is None + _write_record(record_path(repo), sha, {}) + assert needs_bootstrap(repo) is None # New commit → mismatch again. (repo / "f.txt").write_text("2", encoding="utf-8") _git(["add", "."], repo) _git(["commit", "-m", "two"], repo) - assert needs_bootstrap(repo, "home") == _head_sha(repo) + assert needs_bootstrap(repo) == _head_sha(repo) def test_needs_bootstrap_broken_tree_never_fires(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - assert needs_bootstrap(tmp_path / "nope", "home") is None + assert needs_bootstrap(tmp_path / "nope") is None # ── lock protocol ──────────────────────────────────────────────────── @@ -269,20 +260,15 @@ def test_fresh_lock_is_respected(tmp_path): @pytest.fixture def fake_steps(monkeypatch): - calls = {"home": 0, "machine": 0} + calls = {"home": 0} def home_step(): calls["home"] += 1 return {"ok": True} - def machine_step(): - calls["machine"] += 1 - return {"ok": True} - from hermes_cli import post_update monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", (("h", home_step),)) - monkeypatch.setattr(post_update, "BOOT_MACHINE_STEPS", (("m", machine_step),)) return calls @@ -291,16 +277,15 @@ def test_run_boot_bootstrap_runs_then_noops(repo, tmp_path, monkeypatch, fake_st monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) first = run_boot_bootstrap(repo) - assert fake_steps == {"home": 1, "machine": 1} + assert fake_steps == {"home": 1} assert first["home"] == {"h": {"ok": True}} - assert first["machine"] == {"m": {"ok": True}} second = run_boot_bootstrap(repo) - assert fake_steps == {"home": 1, "machine": 1} # no re-run - assert second == {"home": "skipped", "machine": "skipped"} + assert fake_steps == {"home": 1} # no re-run + assert second == {"home": "skipped"} -def test_machine_step_runs_once_across_profiles(repo, tmp_path, monkeypatch, fake_steps): +def test_each_profile_runs_its_own_home_steps(repo, tmp_path, monkeypatch, fake_steps): monkeypatch.setattr(Path, "home", lambda: tmp_path) base = tmp_path / ".hermes" @@ -309,8 +294,8 @@ def test_machine_step_runs_once_across_profiles(repo, tmp_path, monkeypatch, fak monkeypatch.setenv("HERMES_HOME", str(base / "profiles" / "coder")) run_boot_bootstrap(repo) - # Each home bootstraps itself; the machine step fires once. - assert fake_steps == {"home": 2, "machine": 1} + # Each home bootstraps itself. + assert fake_steps == {"home": 2} def test_step_failure_still_writes_record(repo, tmp_path, monkeypatch): @@ -323,15 +308,14 @@ def test_step_failure_still_writes_record(repo, tmp_path, monkeypatch): raise RuntimeError("step exploded") monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", (("boom", boom),)) - monkeypatch.setattr(post_update, "BOOT_MACHINE_STEPS", ()) run_boot_bootstrap(repo) - record = read_last_known(record_path(repo, "home")) + record = read_last_known(record_path(repo)) assert record["identity"] == _head_sha(repo) assert record["results"]["boom"]["ok"] is False # A broken step must not retrigger the slow path every boot. - assert needs_bootstrap(repo, "home") is None + assert needs_bootstrap(repo) is None def test_double_check_under_lock(repo, tmp_path, monkeypatch, fake_steps): @@ -346,7 +330,7 @@ def test_double_check_under_lock(repo, tmp_path, monkeypatch, fake_steps): got = real_acquire(self) if got and self.path.name.endswith(".json.lock"): # Simulate the previous holder completing just before us. - write_record(repo, "home", sha) + _write_record(record_path(repo), sha, {}) return got monkeypatch.setattr(_RecordLock, "acquire", acquire_after_racer_finished) @@ -363,60 +347,6 @@ def test_maybe_run_never_raises(monkeypatch, tmp_path): boot_bootstrap.maybe_run_boot_bootstrap(tmp_path) # must not raise -def test_boot_machine_scope_is_check_only(repo, tmp_path, monkeypatch): - """Automatic boot NEVER installs: a drifted machine must get a drift - report (and a written record), while pm's ensure/sync_venv stay - reserved for the explicit update pass (MACHINE_STEPS).""" - import pm - - from hermes_cli import post_update - - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - - installed: list[str] = [] - monkeypatch.setattr(pm, "check", lambda: ["node: not installed or outdated"]) - - class _Boom: - @staticmethod - def ensure(*_a, **_kw): - installed.append("ensure") - - @staticmethod - def sync_venv(*_a, **_kw): - installed.append("sync_venv") - - monkeypatch.setitem(sys.modules, "pm.ensure", _Boom) - - result = run_boot_bootstrap(repo) - - assert result["machine"]["report_runtime_drift"]["drift"] == [ - "node: not installed or outdated" - ] - assert installed == [], "boot must not install anything" - record = read_last_known(record_path(repo, "machine")) - assert record["results"]["report_runtime_drift"]["ok"] is True - # the installing registry is untouched for the explicit update owner - assert ("provision_runtimes", post_update.step_provision_runtimes) in ( - post_update.MACHINE_STEPS - ) - assert ("report_runtime_drift", post_update.step_report_runtime_drift) in ( - post_update.BOOT_MACHINE_STEPS - ) - - -def test_boot_machine_scope_current_is_skipped(repo, tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - import pm - - monkeypatch.setattr(pm, "check", lambda: []) - result = run_boot_bootstrap(repo) - assert result["machine"] == { - "report_runtime_drift": {"ok": True, "skipped": "current"} - } - - def test_sealed_tree_bootstrap_end_to_end(tmp_path, monkeypatch): """The desktop-bundle-swap scenario. A sealed tree (install-stamp.json, no .git) must bootstrap on first boot, no-op on the second, and RE-RUN @@ -428,11 +358,6 @@ def test_sealed_tree_bootstrap_end_to_end(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - # A sealed tree consults pm for drift; keep the test hermetic. - import pm - - monkeypatch.setattr(pm, "check", lambda: []) - sealed = tmp_path / "payload" sealed.mkdir() (sealed / "install-stamp.json").write_text( @@ -446,7 +371,6 @@ def test_sealed_tree_bootstrap_end_to_end(tmp_path, monkeypatch): return {"ok": True} monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", (("h", count),)) - monkeypatch.setattr(post_update, "BOOT_MACHINE_STEPS", ()) assert run_boot_bootstrap(sealed)["home"] != "skipped" assert calls["n"] == 1 @@ -460,194 +384,3 @@ def test_sealed_tree_bootstrap_end_to_end(tmp_path, monkeypatch): assert run_boot_bootstrap(sealed)["home"] != "skipped" assert calls["n"] == 2, "a swapped bundle must re-run the bootstrap" - - -# ── the per-install state folder ───────────────────────────────────── - - -def test_ensure_install_dir_writes_the_reverse_map(repo, tmp_path, monkeypatch): - """install.json is the sha16 → root reverse map that makes orphan GC - possible. Written once; a second call must not rewrite firstSeen.""" - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - state = boot_bootstrap.ensure_install_dir(repo) - - assert state == boot_bootstrap.install_state_dir(repo) - record = json.loads((state / "install.json").read_text()) - assert record["root"] == str(repo.resolve()) - assert record["steward"] == "checkout" - first_seen = record["firstSeen"] - - boot_bootstrap.ensure_install_dir(repo) - assert json.loads((state / "install.json").read_text())["firstSeen"] == first_seen - - -def test_ensure_install_dir_records_the_sealed_steward(tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - sealed = tmp_path / "payload" - sealed.mkdir() - (sealed / "install-stamp.json").write_text( - json.dumps({"commit": "c" * 40, "distribution": "desktop-app", "updateMechanism": "electron-updater"}) - ) - state = boot_bootstrap.ensure_install_dir(sealed) - record = json.loads((state / "install.json").read_text()) - assert record["steward"] == "desktop-app" - - -def test_orphan_sweep_flags_only_vanished_roots(repo, tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - boot_bootstrap.ensure_install_dir(repo) # alive - ghost = tmp_path / "deleted-checkout" - ghost.mkdir() - ghost_state = boot_bootstrap.ensure_install_dir(ghost) - shutil.rmtree(ghost) # the install is gone; its state folder is not - - orphans = boot_bootstrap.orphaned_installs() - - assert [(folder, root) for folder, root in orphans] == [ - (ghost_state, str(ghost)) - ] - - -def test_bootstrap_records_live_inside_the_state_folder(repo, tmp_path, monkeypatch): - """Both scopes share the install's folder; profile identity rides the - FILENAME. One anchor, not two homes.""" - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) # isolate the default root - - home = boot_bootstrap.record_path(repo, "home") - machine = boot_bootstrap.record_path(repo, "machine") - - state = boot_bootstrap.install_state_dir(repo) - assert home == state / "bootstrap" / "default.json" - assert machine == state / "bootstrap" / "machine.json" - - -class TestSealedDriftBackstop: - """_report_sealed_runtime_drift — every boot of a drifted sealed tree - says so; nothing else makes a sound, and nothing ever gates boot.""" - - def _sealed_tree(self, tmp_path): - root = tmp_path / "sealed" - root.mkdir() - (root / "install-stamp.json").write_text( - json.dumps({"schemaVersion": 2, "commit": "a" * 40, "distribution": "docker", "updateMechanism": "external"}), - encoding="utf-8", - ) - return root - - def test_drifted_sealed_tree_reports_to_stderr(self, tmp_path, capsys, monkeypatch): - import pm - - root = self._sealed_tree(tmp_path) - monkeypatch.setattr(pm, "check", lambda: ["node: not installed or outdated"]) - message = boot_bootstrap._report_sealed_runtime_drift(root) - assert message is not None and "node" in message - err = capsys.readouterr().err - assert "docker" in err and "node" in err - - def test_current_sealed_tree_is_silent(self, tmp_path, capsys, monkeypatch): - import pm - - root = self._sealed_tree(tmp_path) - monkeypatch.setattr(pm, "check", lambda: []) - assert boot_bootstrap._report_sealed_runtime_drift(root) is None - assert capsys.readouterr().err == "" - - def test_checkout_is_silent_even_with_drift(self, tmp_path, capsys, monkeypatch): - """A checkout provisions on demand; drift there is self-healing - and must not produce boot noise.""" - import pm - - root = tmp_path / "co" - (root / ".git").mkdir(parents=True) - monkeypatch.setattr(pm, "check", lambda: ["node: not installed or outdated"]) - assert boot_bootstrap._report_sealed_runtime_drift(root) is None - assert capsys.readouterr().err == "" - - def test_a_broken_check_never_gates_boot(self, tmp_path, monkeypatch): - import pm - - root = self._sealed_tree(tmp_path) - - def explode(): - raise RuntimeError("facts file corrupted") - - monkeypatch.setattr(pm, "check", explode) - assert boot_bootstrap._report_sealed_runtime_drift(root) is None - - def test_drift_lands_in_the_boot_summary(self, tmp_path, monkeypatch): - import pm - - from hermes_cli import post_update - - root = self._sealed_tree(tmp_path) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) - monkeypatch.setattr(pm, "check", lambda: ["uv: not installed or outdated"]) - monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", ()) - monkeypatch.setattr(post_update, "BOOT_MACHINE_STEPS", ()) - summary = run_boot_bootstrap(root) - assert "uv" in summary.get("sealed_runtime_drift", "") - - -class TestOrphanedStoreEntries: - """orphaned_store_entries — pm's facts.json is the only authority, - keep on doubt.""" - - @pytest.fixture - def store(self, tmp_path, monkeypatch): - store = tmp_path / "tools" - store.mkdir(parents=True) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - return store - - def _publish(self, store, name, payload=b"tool bytes"): - entry = store / name - entry.mkdir(parents=True) - (entry / "tool.bin").write_bytes(payload) - return entry - - def _facts(self, store, packages: dict): - (store / "facts.json").write_text( - json.dumps({"schema": 1, "packages": packages}), encoding="utf-8" - ) - - def test_referenced_entries_survive_unreferenced_are_flagged(self, store): - self._publish(store, "ripgrep-15.2.0-win32-x64") - stale = self._publish(store, "ripgrep-14.1.0-win32-x64", b"x" * 512) - self._facts(store, { - "ripgrep": {"entry": "ripgrep-15.2.0-win32-x64", "version": "15.2.0", "env": {}}, - }) - - orphans = boot_bootstrap.orphaned_store_entries() - - assert [(e.name, s) for e, s in orphans] == [ - ("ripgrep-14.1.0-win32-x64", 512) - ] - assert stale.exists() # report-only: nothing was deleted - - def test_no_facts_file_reports_nothing(self, store): - """pm only vouches for what it installed: no ledger, no verdicts.""" - self._publish(store, "gh-2.97.0-win32-x64") - assert boot_bootstrap.orphaned_store_entries() == [] - - def test_unreadable_facts_err_toward_keep(self, store): - """A busted ledger must cost disk, not flag entries it may own.""" - self._publish(store, "gh-2.97.0-win32-x64") - (store / "facts.json").write_text("NOT JSON", encoding="utf-8") - assert boot_bootstrap.orphaned_store_entries() == [] - - def test_scratch_fetch_and_files_are_not_candidates(self, store): - """Scratch dirs are pm's own cleanup, fetch-* entries are the - re-download cache, and stray files are not entries at all.""" - (store / ".staging-abc123").mkdir() - self._publish(store, "fetch-0123456789abcdef") - (store / "stray.txt").write_text("x", encoding="utf-8") - self._facts(store, {}) - assert boot_bootstrap.orphaned_store_entries() == [] diff --git a/tests/hermes_cli/test_boot_convergence.py b/tests/hermes_cli/test_boot_convergence.py new file mode 100644 index 0000000000..c1c85f17af --- /dev/null +++ b/tests/hermes_cli/test_boot_convergence.py @@ -0,0 +1,191 @@ +"""Real startup paths share one passive PM verdict, including fast dispatch.""" +import json +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("surface", ["serve", "version", "gateway"]) +def test_each_start_checks_pm_once_before_dispatch(surface, tmp_path, monkeypatch, capsys, caplog): + import pm + from hermes_cli import boot_bootstrap + + home = tmp_path / "home" + home.mkdir() + runtime = tmp_path / "tools" + runtime.mkdir() + # A recorded but empty store is genuinely drifted against the shipped lock. + (runtime / "facts.json").write_text(json.dumps({"schema": 1, "packages": {}})) + root = tmp_path / "payload" + root.mkdir() + (root / "install-stamp.json").write_text(json.dumps({ + "commit": "abcdef012345", "payload": "bundled", "updateMechanism": "electron-updater", + })) + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root)) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + monkeypatch.delenv("HERMES_DISABLE_FAST_SERVE_LAUNCH", raising=False) + checked = [] + real_check = pm.check + + def check(): + problems = real_check() + assert problems + checked.append(problems) + return problems + + def no_install(*args, **kwargs): + pytest.fail("startup must not install or sync") + + monkeypatch.setattr(pm, "check", check) + monkeypatch.setattr(pm, "ensure", no_install) + monkeypatch.setattr(pm, "sync_venv", no_install) + dispatched = [] + if surface == "gateway": + import gateway.run as gr + + async def start(*args, **kwargs): + dispatched.append(len(checked)) + return True + + monkeypatch.setattr(gr, "start_gateway", start) + monkeypatch.setattr(gr, "_exit_after_graceful_shutdown", lambda code: None) + monkeypatch.setattr(sys, "argv", ["gateway"]) + run = gr.main + else: + from hermes_cli import main + + monkeypatch.setattr(main, "cmd_dashboard", lambda args: dispatched.append(len(checked))) + monkeypatch.setattr(main, "cmd_version", lambda args: dispatched.append(len(checked))) + monkeypatch.setattr(sys, "argv", ["hermes", "serve" if surface == "serve" else "--version"]) + run = main.main + for _ in range(2): + checked.clear() + capsys.readouterr() + caplog.clear() + run() + assert len(checked) == 1 + assert dispatched[-1] == 1 + output = capsys.readouterr().err + warnings = [r for r in caplog.records if "install out of sync" in r.message] + if surface == "gateway": + assert len(warnings) == 1 + else: + assert output.count("install out of sync") == 1 + assert "rebuild the artifact" in (warnings[0].message if surface == "gateway" else output) + assert not list(home.rglob("machine.json")) + assert boot_bootstrap.current_install_identity(root) == "abcdef012345" + + +@pytest.mark.parametrize("sibling_profile", [False, True]) +def test_concurrent_boots_bound_real_home_migration(tmp_path, monkeypatch, sibling_profile): + import os + import subprocess + import sqlite3 + import time + from hermes_cli.config import DEFAULT_CONFIG + + root = tmp_path / "payload" + root.mkdir() + (root / "install-stamp.json").write_text(json.dumps({ + "commit": "abcdef012345", "updateMechanism": "external", "distribution": "nix", + })) + home = tmp_path / "home" + other = home / "profiles/coder" if sibling_profile else home + for directory in {home, other}: + directory.mkdir(parents=True, exist_ok=True) + (directory / "config.yaml").write_text(f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\n") + (directory / ".env").write_text("EXAMPLE_KEY=test\n") + with sqlite3.connect(directory / "state.db") as db: + db.execute("CREATE TABLE retained (value)") + db.execute("INSERT INTO retained VALUES ('before')") + entered = tmp_path / "entered" + release = tmp_path / "release" + script = tmp_path / "boot.py" + script.write_text('''import json, sys, time +from pathlib import Path +from hermes_cli import boot_bootstrap, post_update +root, entered, release = map(Path, sys.argv[1:4]) +def migrate(): + if sys.argv[4] == 'hold': + entered.touch() + deadline = time.monotonic() + 30 + while not release.exists(): + if time.monotonic() > deadline: raise RuntimeError('release timeout') + time.sleep(0.02) + return post_update.step_migrate_config() +post_update.BOOT_HOME_STEPS = (('migrate', migrate), ('db', post_update.step_state_db_guard)) +print(json.dumps(boot_bootstrap.run_boot_bootstrap(root))) +''') + env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(home), + HERMES_RUNTIME_DIR=str(tmp_path / "tools"), + PYTHONPATH=str(Path(__file__).resolve().parents[2])) + command = [sys.executable, str(script), str(root), str(entered), str(release)] + first = subprocess.Popen([*command, "hold"], env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 30 + while not entered.exists(): + assert first.poll() is None + assert time.monotonic() < deadline + time.sleep(0.02) + second = subprocess.run([*command, "go"], env=dict(env, HERMES_HOME=str(other)), + capture_output=True, text=True, timeout=30) + assert second.returncode == 0, second.stderr + result = json.loads(second.stdout.splitlines()[-1]) + if sibling_profile: + assert result["home"]["migrate"]["ok"] + assert result["home"]["db"]["ok"] + else: + assert result == {"home": "lost-race"} + release.touch() + stdout, stderr = first.communicate(timeout=30) + assert first.returncode == 0, stderr + assert json.loads(stdout.splitlines()[-1])["home"]["db"]["ok"] + again = subprocess.run([*command, "go"], env=env, capture_output=True, text=True, timeout=30) + assert json.loads(again.stdout.splitlines()[-1]) == {"home": "skipped"} + for directory in {home, other}: + assert len(list(directory.glob("config.yaml.bak-*"))) == 1 + with sqlite3.connect(directory / "state.db") as db: + assert db.execute("SELECT value FROM retained").fetchall() == [("before",)] + finally: + release.touch() + if first.poll() is None: + first.kill() + first.communicate(timeout=30) + + +def test_failed_migration_is_restored_and_not_retried(tmp_path, monkeypatch): + from hermes_cli import boot_bootstrap, config, post_update + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + root = tmp_path / "payload" + root.mkdir() + (root / "install-stamp.json").write_text(json.dumps({ + "commit": "abcdef012345", "updateMechanism": "external", "distribution": "nix", + })) + config_file = home / "config.yaml" + config_file.write_text(f"_config_version: {config.DEFAULT_CONFIG['_config_version'] - 1}\n") + env_file = home / ".env" + env_file.write_text("EXAMPLE_KEY=keep\n") + original = {p: p.read_bytes() for p in (config_file, env_file)} + calls = [] + + def interrupted(**kwargs): + calls.append(True) + config_file.write_text("half written") + env_file.write_text("half written") + raise RuntimeError("interrupted migration") + + monkeypatch.setattr(config, "migrate_config", interrupted) + monkeypatch.setattr(post_update, "BOOT_HOME_STEPS", (("migrate", post_update.step_migrate_config),)) + assert not boot_bootstrap.run_boot_bootstrap(root)["home"]["migrate"]["ok"] + assert {p: p.read_bytes() for p in original} == original + assert boot_bootstrap.run_boot_bootstrap(root) == {"home": "skipped"} + assert calls == [True] diff --git a/tests/hermes_cli/test_boot_wiring.py b/tests/hermes_cli/test_boot_wiring.py index 7cdb2caa66..f212687469 100644 --- a/tests/hermes_cli/test_boot_wiring.py +++ b/tests/hermes_cli/test_boot_wiring.py @@ -81,11 +81,6 @@ def test_boot_bootstrap_reaches_post_update_registry(hermes_home, tmp_path, monk "BOOT_HOME_STEPS", (("probe_home", lambda: (ran.append("home"), {"ok": True})[1]),), ) - monkeypatch.setattr( - post_update, - "BOOT_MACHINE_STEPS", - (("probe_machine", lambda: (ran.append("machine"), {"ok": True})[1]),), - ) root = tmp_path / "install" root.mkdir() @@ -102,10 +97,10 @@ def test_boot_bootstrap_reaches_post_update_registry(hermes_home, tmp_path, monk boot_bootstrap.maybe_run_boot_bootstrap(root) - assert sorted(ran) == ["home", "machine"] + assert sorted(ran) == ["home"] # second boot: record-gated no-op, no doubled maintenance boot_bootstrap.maybe_run_boot_bootstrap(root) - assert sorted(ran) == ["home", "machine"] + assert sorted(ran) == ["home"] # ── gateway entry point reaches the registry ───────────────────────── diff --git a/tests/hermes_cli/test_post_update.py b/tests/hermes_cli/test_post_update.py index ff8e6b49ad..0fdf4c2aff 100644 --- a/tests/hermes_cli/test_post_update.py +++ b/tests/hermes_cli/test_post_update.py @@ -166,10 +166,34 @@ def test_provisioning_is_the_machine_scope_driver_path(): assert not any("cua" in name for name in names) +def test_provisioning_does_not_use_human_diagnostics(tmp_path, monkeypatch): + import json + import importlib + import pm + from pm import paths + + engine = importlib.import_module("pm.ensure") + runtime = tmp_path / "tools" + runtime.mkdir() + (runtime / "facts.json").write_text(json.dumps({"schema": 1, "packages": {}})) + lock = tmp_path / "lock.json" + lock.write_text(json.dumps({"schema": 1, "packages": {"node": {"version": "test"}}})) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + monkeypatch.setattr(paths, "lockfile_path", lambda: lock) + monkeypatch.setattr(engine, "sealed", lambda: False) + monkeypatch.setattr(engine, "lazy_installs_allowed", lambda: True) + monkeypatch.setattr(pm, "check", lambda: ["translated diagnostic without a package token"]) + ensured = [] + monkeypatch.setattr(pm, "ensure", lambda name, **kwargs: ensured.append((name, kwargs))) + + assert post_update.main(["--scope", "machine"]) == 0 + assert ensured == [("node", {"explicit": True})] + + def test_provision_runtimes_is_a_noop_when_pm_is_current(monkeypatch): import pm - monkeypatch.setattr(pm, "check", lambda: []) + monkeypatch.setattr(pm, "drift", lambda: {}) assert post_update.step_provision_runtimes() == {"ok": True, "skipped": "current"} @@ -184,7 +208,7 @@ def test_provision_runtimes_reensures_only_what_pm_names(monkeypatch): pm_ensure = importlib.import_module("pm.ensure") ensured = [] - monkeypatch.setattr(pm, "check", lambda: ["node: not installed or outdated", "venv: out of sync with uv.lock"]) + monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated", "venv": "out of sync"}) monkeypatch.setattr(pm_ensure, "sealed", lambda: False) monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(pm, "ensure", lambda name, explicit=False: ensured.append((name, explicit))) @@ -203,7 +227,7 @@ def test_provision_runtimes_respects_the_lazy_install_policy(monkeypatch): pm_ensure = importlib.import_module("pm.ensure") - monkeypatch.setattr(pm, "check", lambda: ["node: not installed or outdated"]) + monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated"}) monkeypatch.setattr(pm_ensure, "sealed", lambda: False) monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: False) result = post_update.step_provision_runtimes() diff --git a/tests/hermes_cli/test_post_update_expose_cli.py b/tests/hermes_cli/test_post_update_expose_cli.py index c7ea6d9850..fb453400bd 100644 --- a/tests/hermes_cli/test_post_update_expose_cli.py +++ b/tests/hermes_cli/test_post_update_expose_cli.py @@ -1,4 +1,4 @@ -"""step_expose_cli — the post-update side owns launcher-wrapper repair. +"""expose_cli — the launcher owner repairs PATH conveniences. The installers write ~/.local/bin/hermes* once, at install time. This step rewrites them when they drift (moved checkout, recreated venv, @@ -20,11 +20,9 @@ from pathlib import Path import pytest -from hermes_cli import post_update +from hermes_cli import _launchers, post_update -posix_only = pytest.mark.skipif( - sys.platform == "win32", reason="wrapper exposure is POSIX-only; Windows is installer-owned" -) +posix_only = pytest.mark.platforms("posix") def _write_bundled_stamp(repo_root: Path) -> None: @@ -42,6 +40,7 @@ def fake_install(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + monkeypatch.setenv("HERMES_HOME", str(home)) root = tmp_path / "checkout" (root / "venv" / "bin").mkdir(parents=True) @@ -49,50 +48,59 @@ def fake_install(tmp_path, monkeypatch): (root / "hermes").write_text("# entrypoint\n") (root / "run_agent.py").write_text("# agent\n") monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root)) + store = tmp_path / "tools" + store.mkdir() + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + interpreter = Path(sys._base_executable).resolve() + (store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": { + "entry": str(interpreter.parent if os.name == "nt" else interpreter.parents[1]), + }}})) return home, root -def test_windows_is_installer_owned(monkeypatch): - if sys.platform != "win32": - monkeypatch.setattr(post_update.sys, "platform", "win32") - assert post_update.step_expose_cli() == { +@pytest.mark.platforms("windows") +def test_windows_is_installer_owned(): + assert _launchers.expose_cli() == { "ok": True, "skipped": "windows-installer-owned", } def test_registered_as_a_home_step(): - assert ("expose_cli", post_update.step_expose_cli) in post_update.HOME_STEPS + assert ("expose_cli", _launchers.expose_cli) in post_update.HOME_STEPS -@posix_only class TestExposeCli: + @posix_only def test_writes_all_three_wrappers_fresh(self, fake_install): home, root = fake_install - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert result["ok"] is True assert sorted(result["written"]) == ["hermes", "hermes-acp", "hermes-agent"] for name in ("hermes", "hermes-agent", "hermes-acp"): wrapper = home / ".local" / "bin" / name body = wrapper.read_text() assert str(root) in body - assert "PYTHONPATH" in body + assert str(root / ".hermes" / "bin") in body assert os.access(wrapper, os.X_OK) + @posix_only def test_second_run_is_a_no_op(self, fake_install): - post_update.step_expose_cli() - result = post_update.step_expose_cli() + _launchers.expose_cli() + result = _launchers.expose_cli() assert result == {"ok": True, "written": []} + @posix_only def test_repairs_a_stale_same_install_wrapper(self, fake_install): home, root = fake_install - post_update.step_expose_cli() + _launchers.expose_cli() wrapper = home / ".local" / "bin" / "hermes" wrapper.write_text(f'#!/bin/sh\nexec "{root}/venv/bin/python" OLD-SHAPE\n') - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert "hermes" in result["written"] assert "OLD-SHAPE" not in wrapper.read_text() + @posix_only def test_leaves_another_installs_wrapper_alone(self, fake_install): home, root = fake_install other = "/somewhere/else/checkout" @@ -100,28 +108,31 @@ class TestExposeCli: wrapper_dir.mkdir(parents=True) foreign = f'#!/bin/sh\nexec "{other}/venv/bin/python" "{other}/hermes" "$@"\n' (wrapper_dir / "hermes").write_text(foreign) - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert (wrapper_dir / "hermes").read_text() == foreign assert "hermes" not in result["written"] # The other two had no file at all — those ARE written. assert sorted(result["written"]) == ["hermes-acp", "hermes-agent"] + @posix_only def test_config_gate_disables(self, fake_install, monkeypatch): monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"cli": {"expose_on_path": False}}, ) - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert result == {"ok": True, "skipped": "config-disabled"} + @posix_only def test_sealed_tree_without_venv_skips(self, fake_install, monkeypatch, tmp_path): + (tmp_path / "tools" / "facts.json").unlink() bare = tmp_path / "sealed" bare.mkdir() monkeypatch.setenv("HERMES_INSTALL_ROOT", str(bare)) - result = post_update.step_expose_cli() - assert result == {"ok": True, "skipped": "no-venv-layout"} + result = _launchers.expose_cli() + assert result == {"ok": True, "skipped": "no-store-python"} - @pytest.mark.skipif(sys.platform == "darwin", reason="darwin takes the symlink branch") + @pytest.mark.platforms("linux") def test_bundled_tree_skips_bundle_owns_launchers( self, fake_install, monkeypatch, tmp_path ): @@ -134,9 +145,10 @@ class TestExposeCli: for name in ("hermes", "hermes-agent", "hermes-acp"): (payload / "bin" / name).write_text("\x7fELF fake shim\n") monkeypatch.setenv("HERMES_INSTALL_ROOT", str(payload / "repo")) - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert result == {"ok": True, "skipped": "bundle-owns-launchers"} + @posix_only def test_unstamped_tree_with_sibling_bin_is_not_a_bundle( self, fake_install, monkeypatch, tmp_path ): @@ -144,16 +156,18 @@ class TestExposeCli: PARENT happens to carry a bin/hermes (the installers' launcher dir shares ~/.hermes with the checkout) must skip, not enter the sealed branch — on every platform.""" + (tmp_path / "tools" / "facts.json").unlink() parent = tmp_path / "hermes-home" (parent / "bin").mkdir(parents=True) (parent / "bin" / "hermes").write_text("#!/bin/sh\n# installer launcher\n") checkout = parent / "hermes-agent" checkout.mkdir() monkeypatch.setenv("HERMES_INSTALL_ROOT", str(checkout)) - result = post_update.step_expose_cli() - assert result == {"ok": True, "skipped": "no-venv-layout"} - assert post_update._is_bundled_payload(checkout) is False + result = _launchers.expose_cli() + assert result == {"ok": True, "skipped": "no-store-python"} + assert _launchers._is_bundled_payload(checkout) is False + @posix_only def test_replaces_a_dangling_symlink_from_old_installs(self, fake_install): """#21454: `cat >` used to follow an old symlink into the venv and clobber the console script. The step must unlink FIRST.""" @@ -163,7 +177,7 @@ class TestExposeCli: console_script = root / "venv" / "bin" / "hermes" console_script.write_text("# real console script\n") (wrapper_dir / "hermes").symlink_to(console_script) - result = post_update.step_expose_cli() + result = _launchers.expose_cli() assert "hermes" in result["written"] # The venv console script survives untouched… assert console_script.read_text() == "# real console script\n" @@ -171,11 +185,42 @@ class TestExposeCli: assert not (wrapper_dir / "hermes").is_symlink() +@pytest.mark.platforms("macos") +def test_direct_packaged_cli_exposes_shims_before_electron(tmp_path): + import subprocess + import shlex + + home = tmp_path / "home" + home.mkdir() + payload = tmp_path / "Hermes.app/Contents/Resources/agent-payload" + repo = payload / "repo" + _write_bundled_stamp(repo) + (repo / "install-stamp.json").write_text(json.dumps({ + "payload": "bundled", "commit": "abcdef012345", "updateMechanism": "electron-updater", + })) + bin_dir = payload / "bin" + bin_dir.mkdir() + code = f"import sys; sys.path.insert(0, {str(Path(__file__).resolve().parents[2])!r}); from hermes_cli.main import main; main()" + for name in ("hermes", "hermes-agent", "hermes-acp"): + shim = bin_dir / name + shim.write_text(f'#!/bin/sh\nexec {shlex.join([sys.executable, "-I", "-c", code])} "$@"\n') + shim.chmod(0o755) + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(home / ".hermes"), + HERMES_INSTALL_ROOT=str(repo), HERMES_RUNTIME_DIR=str(tmp_path / "tools")) + # Execute the package CLI directly. No Electron process or linking helper runs. + result = subprocess.run([str(bin_dir / "hermes"), "--version"], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + for name in ("hermes", "hermes-agent", "hermes-acp"): + assert (home / ".local/bin" / name).is_symlink() + assert (home / ".local/bin" / name).resolve() == bin_dir / name + + @posix_only class TestSymlinkSealedLaunchers: """The macOS sealed-bundle exposure helper, tested directly — the symlink/ownership logic is platform-free; only its call site in - step_expose_cli is darwin-gated.""" + expose_cli is darwin-gated.""" @pytest.fixture def payload(self, tmp_path, monkeypatch): @@ -190,7 +235,7 @@ class TestSymlinkSealedLaunchers: def test_links_all_three_fresh(self, payload): home, payload_bin = payload - result = post_update._symlink_sealed_launchers(payload_bin) + result = _launchers._symlink_sealed_launchers(payload_bin) assert result["ok"] is True assert sorted(result["written"]) == ["hermes", "hermes-acp", "hermes-agent"] for name in ("hermes", "hermes-agent", "hermes-acp"): @@ -200,8 +245,8 @@ class TestSymlinkSealedLaunchers: def test_second_run_is_a_no_op(self, payload): _, payload_bin = payload - post_update._symlink_sealed_launchers(payload_bin) - result = post_update._symlink_sealed_launchers(payload_bin) + _launchers._symlink_sealed_launchers(payload_bin) + result = _launchers._symlink_sealed_launchers(payload_bin) assert result["written"] == [] def test_retargets_own_link_after_app_moved(self, payload, tmp_path): @@ -212,7 +257,7 @@ class TestSymlinkSealedLaunchers: link_dir = home / ".local" / "bin" link_dir.mkdir(parents=True) (link_dir / "hermes").symlink_to(old / "hermes") # dangling, old payload path - result = post_update._symlink_sealed_launchers(payload_bin) + result = _launchers._symlink_sealed_launchers(payload_bin) assert "hermes" in result["written"] assert os.readlink(link_dir / "hermes") == str(payload_bin / "hermes") @@ -226,7 +271,7 @@ class TestSymlinkSealedLaunchers: other = tmp_path / "other-tool" other.write_text("other\n") (link_dir / "hermes-agent").symlink_to(other) - result = post_update._symlink_sealed_launchers(payload_bin) + result = _launchers._symlink_sealed_launchers(payload_bin) assert (link_dir / "hermes").read_text() == "#!/bin/sh\n# pipx launcher\n" assert os.readlink(link_dir / "hermes-agent") == str(other) assert sorted(result["written"]) == ["hermes-acp"] diff --git a/tests/hermes_cli/test_source_launcher_publication.py b/tests/hermes_cli/test_source_launcher_publication.py index d021a4eaf0..9be313a798 100644 --- a/tests/hermes_cli/test_source_launcher_publication.py +++ b/tests/hermes_cli/test_source_launcher_publication.py @@ -127,6 +127,45 @@ def test_materializer_cli_refuses_missing_store_without_publishing(tmp_path, mon assert not out.exists() or not list(out.iterdir()) +@pytest.mark.platforms("posix") +def test_boot_migrates_legacy_conveniences_to_selected_runtime(tmp_path, monkeypatch): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(repo)) + selected = install_state_dir(repo) / "environments" / "current" / "venv" + site = site_packages(selected) + site.mkdir(parents=True) + (selected / "pyvenv.cfg").write_text("home = fixture\n") + (site / "selected_probe.py").write_text("VALUE = 'migrated'\n") + (install_state_dir(repo) / "facts.json").write_text(json.dumps({ + "schema": 1, "packages": {"venv": {"environment": str(selected)}}})) + out = home / ".local" / "bin" + out.mkdir(parents=True) + # Old venv and sibling-ACP wrappers, with an unrelated command sharing bin. + (out / "hermes").write_text(f'#!/bin/sh\nexec "{repo}/venv/bin/python" "{repo}/hermes" "$@"\n') + (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') + foreign = f'#!/bin/sh\n# user note about {repo}\nexit 19\n' + (out / "hermes-agent").write_text(foreign) + + 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) + 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() == 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 @@ -297,6 +336,34 @@ def test_service_survives_python_tool_replacement(tmp_path, monkeypatch): assert json.loads(result.stdout)["value"] == "ready" +@pytest.mark.platforms("posix") +def test_sync_migrates_old_store_wrapper_before_python_collection(tmp_path, monkeypatch): + from hermes_cli.venv_sync import publish_launchers + + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + monkeypatch.setattr(Path, "home", lambda: home) + site = site_packages(repo / "venv") + site.mkdir(parents=True) + (site / "selected_probe.py").write_text("VALUE = 'ready'\n") + out = home / ".local/bin" + out.mkdir(parents=True) + 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}}})) + if version == "python-A": + _launchers.mint_launcher("hermes", repo, out, python, None) + else: + publish_launchers(repo) + shutil.rmtree(store / "python-A") + result = subprocess.run([str(out / "hermes")], cwd=tmp_path, + capture_output=True, text=True, timeout=30) + assert result.returncode == 7, result.stderr + assert json.loads(result.stdout)["value"] == "ready" + + def test_update_import_probe_uses_selected_dependencies(tmp_path, monkeypatch): from hermes_cli import update_cmd, update_cmd_validation From 5c72dc0c6d7a6e88031075770d57cc5ec76ee3ac Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:00:49 -0400 Subject: [PATCH 08/33] fix(build): protect symlinked desktop source inputs A source child can resolve outside the checkout. The output guard must protect its canonical path before the builder checks prepared inputs. Exclude generated dist/build trees so in-tree products can still rebuild. Keep explicit prepared inputs protected even inside generated trees. The public buildDesktop regression test first failed with a missing-icon error instead of an overlap error. All 7 desktop-builder tests now pass, including real cold/warm builds under apps/desktop/build/products. Node syntax checks and git diff --check pass. No full suite or native packaging ran. --- scripts/build/desktop.mjs | 8 +++++--- tests-js/desktop-builder.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/build/desktop.mjs b/scripts/build/desktop.mjs index ddd650589e..9b0aaa0634 100644 --- a/scripts/build/desktop.mjs +++ b/scripts/build/desktop.mjs @@ -29,9 +29,11 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type if (!icons || !stamp || !nativeDeps) throw new Error('icons, stamp and nativeDeps are required prepared inputs') const app = 'apps/desktop' ;({ source, out } = productOutput(source, out, [ - // productOutput owns source/generated classification. Protect prepared - // inputs explicitly, including dependency symlinks outside the checkout. - `${app}/node_modules`, 'node_modules', + // Source children can be symlinks outside the checkout. Protect their + // canonical paths, but leave generated dist/build trees to productOutput. + ...readdirSync(join(resolve(source), app)).filter(name => !['dist', 'build'].includes(name)).map(name => `${app}/${name}`), + `${app}/scripts`, 'scripts/build', 'package.json', 'package-lock.json', + 'apps/shared', 'node_modules', ...[join(resolve(icons), app, 'public'), stamp, nativeDeps].map(input => relative(resolve(source), resolve(input))), ])) const publicIcons = join(resolve(icons), app, 'public') diff --git a/tests-js/desktop-builder.test.mjs b/tests-js/desktop-builder.test.mjs index 6eac116656..5a44be30cf 100644 --- a/tests-js/desktop-builder.test.mjs +++ b/tests-js/desktop-builder.test.mjs @@ -97,6 +97,27 @@ test('desktop compiler consumes explicit immutable inputs, replaces variants, an expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false) }, 60000) +test('desktop output cannot overlap a source directory symlinked outside the checkout', async () => { + const { buildDesktop } = await import('../scripts/build/desktop.mjs') + const root = mkdtempSync(join(tmpdir(), 'desktop symlinked source-')) + roots.push(root) + const source = join(root, 'source') + const externalSource = join(root, 'external-source') + mkdirSync(join(source, 'apps/desktop'), { recursive: true }) + put(join(externalSource, 'index.js'), 'source must not change') + symlinkSync(externalSource, join(source, 'apps/desktop/src'), 'junction') + const out = join(externalSource, 'product') + const before = files(root) + + // Missing prepared inputs must not hide a failure to reject source overlap. + await expect(buildDesktop({ source, out, + icons: join(root, 'icons'), stamp: join(root, 'stamp.json'), nativeDeps: join(root, 'native'), + })).rejects.toThrow(/Output must not overlap build inputs/) + expect(files(root)).toEqual(before) + expect(readdirSync(externalSource)).toEqual(['index.js']) + expect(existsSync(out)).toBe(false) +}) + test('in-tree desktop products rebuild after build exists without replacing prepared inputs', async () => { const { buildDesktop } = await import('../scripts/build/desktop.mjs') const { productCurrent } = await import('../scripts/build/freshness.mjs') From 03d3c466973892fe35543a5a2b95cdc128a3214a Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:02:04 -0400 Subject: [PATCH 09/33] fix(boot): keep shared launcher defaults profile-independent Named-profile boot republishes shared source launchers. Derive their default home from the dependency root so a fresh default launch cannot inherit that profile. Explicit HERMES_HOME overrides remain unchanged. The real-launcher regression failed before the fix. Focused tests: 109 passed, 37 skipped. Native Windows execution was not available. Two installer-stage failures also reproduce with the original writer. --- hermes_cli/_launchers.py | 6 +- .../test_source_launcher_publication.py | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 3113106456..30094e0251 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -21,7 +21,7 @@ if __name__ == "__main__": sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from hermes_constants import get_hermes_home -from hermes_cli.runtime_paths import store_root +from hermes_cli.runtime_paths import dependency_home_root, store_root def runtime_command(repo_root: Path, args=(), *, module: str = "hermes_cli.main", @@ -222,9 +222,11 @@ def mint_launcher( def _launcher_script(name: str, repo_root: Path, dependencies: Path | None) -> str: module, func = ENTRY_POINTS[name] + # Profile boot repairs shared launchers: their default must stay at the + # install's dependency root, not whichever profile triggered publication. return ( "import os, re, sys\n" - f"os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or {str(get_hermes_home())!r}\n" + f"os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or {str(dependency_home_root())!r}\n" "os.environ.pop('PYTHONHOME', None)\n" "os.environ.pop('PYTHONPATH', None)\n" f"sys.path.insert(0, {str(repo_root.resolve())!r})\n" diff --git a/tests/hermes_cli/test_source_launcher_publication.py b/tests/hermes_cli/test_source_launcher_publication.py index 9be313a798..40d57353ba 100644 --- a/tests/hermes_cli/test_source_launcher_publication.py +++ b/tests/hermes_cli/test_source_launcher_publication.py @@ -100,6 +100,69 @@ def test_source_launchers_boot_selected_generation_from_custom_home(tmp_path, mo assert not (repo / "venv").exists() +@pytest.mark.parametrize("publisher", [ + pytest.param("boot", 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") + site = site_packages(repo / "venv") + site.mkdir(parents=True) + (site / "selected_probe.py").write_text("VALUE = 'ready'\n", encoding="utf-8") + 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() + 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() + assert_home(other_home, other_home) + + @pytest.mark.platforms("posix") def test_posix_materializer_publishes_only_executable_shell_launchers(tmp_path, monkeypatch): repo, _home, _interpreter = fixture_tree(tmp_path, monkeypatch) From cfebdfd46683f66a5f0163f07a6072d9297f220c Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:02:46 -0400 Subject: [PATCH 10/33] Own passive source update checks in Python Use the exact target root and profile for branch, release, and cache decisions. Keep the desktop as a transport and handoff adapter. Remove the competing TypeScript checker and switch the banner, dashboard, and updater count consumers. Preserve fork origins, unknown counts, publication checks, old-probe recovery, and official SSH branch healing through public HTTPS. Keep the historical unstamped-root policy unchanged. --- apps/desktop/electron/main.ts | 69 +--- .../desktop/electron/update-api-check.test.ts | 81 ----- apps/desktop/electron/update-api-check.ts | 111 ------ .../electron/update-root-policy.test.ts | 57 --- apps/desktop/electron/update-root-policy.ts | 94 ----- .../updater/checkout-check-live.test.ts | 91 ----- .../electron/updater/checkout-check.test.ts | 156 -------- .../electron/updater/checkout-check.ts | 207 ----------- .../electron/updater/checkout-legacy.test.ts | 15 +- .../updater/checkout-ownership.test.ts | 91 ++--- .../electron/updater/checkout-source.test.ts | 54 +-- .../electron/updater/checkout-source.ts | 37 +- apps/desktop/electron/updater/checkout.ts | 16 +- evals/cli_deferred_notice.py | 12 +- evals/update_check_ssh_pty.py | 6 +- hermes_cli/_startup_fast.py | 4 +- hermes_cli/banner.py | 335 +----------------- hermes_cli/profiles.py | 2 +- hermes_cli/source_check.py | 279 +++++++++++++++ hermes_cli/source_releases.py | 41 +-- hermes_cli/update_cmd.py | 35 +- hermes_cli/web_routers/actions.py | 13 +- tests/hermes_cli/test_banner_git_state.py | 98 +---- .../hermes_cli/test_banner_release_channel.py | 28 +- tests/hermes_cli/test_commit_build_updates.py | 17 +- .../test_dashboard_admin_endpoints.py | 2 +- .../hermes_cli/test_passive_update_opt_out.py | 21 -- tests/hermes_cli/test_shallow_graft_prune.py | 2 +- tests/hermes_cli/test_source_check.py | 279 +++++++++++++++ tests/hermes_cli/test_source_release_probe.py | 4 +- .../test_update_apply_shallow_count.py | 6 +- .../test_update_behind_count_recovery.py | 123 +------ tests/hermes_cli/test_update_check.py | 113 +----- 33 files changed, 728 insertions(+), 1771 deletions(-) delete mode 100644 apps/desktop/electron/update-api-check.test.ts delete mode 100644 apps/desktop/electron/update-api-check.ts delete mode 100644 apps/desktop/electron/update-root-policy.test.ts delete mode 100644 apps/desktop/electron/update-root-policy.ts delete mode 100644 apps/desktop/electron/updater/checkout-check-live.test.ts delete mode 100644 apps/desktop/electron/updater/checkout-check.test.ts delete mode 100644 apps/desktop/electron/updater/checkout-check.ts create mode 100644 hermes_cli/source_check.py create mode 100644 tests/hermes_cli/test_source_check.py diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 69554ade9e..d802875b69 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -411,7 +411,6 @@ import { } from './translucency' import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' -import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { resolveUpdaterMechanism, type UpdaterApplyResultWire, @@ -2976,12 +2975,6 @@ function runGit(args, options: any = {}): Promise<{ code: number; stdout: string const firstLine = text => (text || '').split('\n').find(Boolean) || '' -async function getOriginUrl(updateRoot) { - const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) - - return origin.code === 0 ? origin.stdout.trim() : '' -} - function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) @@ -2991,35 +2984,6 @@ function emitUpdateProgress(payload) { } } -// Self-heal the tracked update branch: if origin no longer publishes it (e.g. -// bb/gui was merged into main and deleted), fall back to main and persist so -// every later check/apply follows main — no manual flip, even for already- -// installed clients. Read-only ls-remote probe; only flips on a definitive -// "ref absent" (exit 2), never on a transient network error, so a flaky -// connection can't strand a user on the wrong branch. -async function resolveHealedBranch(updateRoot, branch) { - if (!branch || branch === 'main') { - return branch || 'main' - } - - const originUrl = await getOriginUrl(updateRoot) - const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' - const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) - - if (probe.code !== 2) { - return branch - } - - rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`) - const config = readDesktopUpdateConfig() - - if (config.branch !== 'main') { - writeDesktopUpdateConfig({ ...config, branch: 'main' }) - } - - return 'main' -} - async function checkUpdates(opts: { force?: boolean } = {}): Promise { // A packaged install delegates to the update owner named by its stamp. let strategy: UpdaterStrategy | null = null @@ -3044,19 +3008,6 @@ async function checkUpdates(opts: { force?: boolean } = {}): Promise { - const response = await fetch(url, { - headers: { Accept: accept, 'User-Agent': 'hermes-desktop-update-check' }, - signal: AbortSignal.timeout(10_000) - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - - return accept === 'application/vnd.github.sha' ? response.text() : response.json() -} - let updateInFlight = false // ── bundled / App Installer helpers ───────────────────────────────────────── @@ -3191,24 +3142,17 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { defaultUpdateBranch: DEFAULT_UPDATE_BRANCH, updateHandoffDwellMs: UPDATE_HANDOFF_DWELL_MS, directoryExists, - readCanonicalInstallStamp, - readDesktopUpdateConfig, - readSourceUpdate: (updateRoot: string): Promise => readSourceUpdate({ + readSourceUpdate: (updateRoot: string, opts: { force?: boolean }): Promise => readSourceUpdate({ python: findPythonForRoot(updateRoot), git: resolveGitBinary(), updateRoot, - hermesHome: HERMES_HOME + hermesHome: HERMES_HOME, + branchConfigPath: DESKTOP_UPDATE_CONFIG_PATH, + force: opts.force }), resolveUpdateRoot, resolveUpdaterBinary, - resolveHealedBranch, - getOriginUrl, - runGit, firstLine, - fetchGitHubApi, - isGitCheckout, - updateCheckCachePath: path.join(app.getPath('userData'), 'update-check-cache.json'), - writeFileAtomic, emitUpdateProgress, rememberLog, @@ -3911,9 +3855,8 @@ async function handOffWindowsBootstrapRecovery(reason) { const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() - const branch = isGitCheckout(updateRoot) - ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) - : configuredBranch || DEFAULT_UPDATE_BRANCH + // Recovery can run without Python. Keep the chosen branch; do not guess a replacement. + const branch: string = configuredBranch || DEFAULT_UPDATE_BRANCH const updaterArgs: string[] = chooseUpdaterArgs( { runtimeUsable: isSourceRuntimeUsable(updateRoot) }, diff --git a/apps/desktop/electron/update-api-check.test.ts b/apps/desktop/electron/update-api-check.test.ts deleted file mode 100644 index 9da6e47eba..0000000000 --- a/apps/desktop/electron/update-api-check.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Tests for electron/update-api-check.ts — the API-first passive update check. - * - * Why this exists: every desktop client used to `git fetch` twice every 30 - * minutes. GitHub measured tens of millions of fetch/clone requests per day - * from the install base and asked us to poll via the API instead. These pin - * the two load-bearing contracts: the cache answers passive checks for a full - * day but invalidates the moment HEAD moves, and the compare payload maps to - * an honest behind count (never a fabricated one). - */ - -import assert from 'node:assert/strict' - -import { test } from 'vitest' - -import { - branchTipApiUrl, - cacheIsFresh, - githubRepoSlug, - parseCompare, - UPDATE_CHECK_FAILURE_TTL_MS, - UPDATE_CHECK_TTL_MS -} from './update-api-check' - -const SHA_A = 'a'.repeat(40) -const SHA_B = 'b'.repeat(40) -const HOUR = 60 * 60 * 1000 - -test('cache serves a passive check for 24h, but not once HEAD or the branch changes', () => { - const cached = { fetchedAt: 0, currentSha: SHA_A, branch: 'main', status: { behind: 0 } } - - assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS - 1 }), true) - assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS }), false) - // Applying an update moves HEAD: a stale "update available" must never survive it. - assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_B, now: 1 }), false) - assert.equal(cacheIsFresh(cached, { branch: 'bb/gui', currentSha: SHA_A, now: 1 }), false) - - // Failures retry sooner than successes, but still not on every tick. - const failed = { ...cached, status: { error: 'fetch-failed' } } - assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_FAILURE_TTL_MS - 1 }), true) - assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: 2 * HOUR }), false) -}) - -test('compare payload maps to the behind count and a newest-first commit list; malformed = null', () => { - const payload = { - ahead_by: 2, - status: 'ahead', - commits: [ - { - sha: SHA_A, - commit: { message: 'fix: older\n\nbody', author: { name: 'A' }, committer: { date: '2026-09-10T00:00:00Z' } } - }, - { - sha: SHA_B, - commit: { message: 'feat: newer', author: { name: 'B' }, committer: { date: '2026-09-10T01:00:00Z' } } - } - ] - } - - const parsed = parseCompare(payload) - assert.equal(parsed?.behind, 2) - assert.deepEqual( - parsed?.commits.map(c => [c.sha, c.summary, c.author]), - [ - [SHA_B, 'feat: newer', 'B'], - [SHA_A, 'fix: older', 'A'] - ] - ) - - assert.equal(parseCompare({ ahead_by: -1 }), null) - assert.equal(parseCompare({ status: 'ahead' }), null) - assert.equal(parseCompare('nope'), null) - - // Forks and SSH forms hit the API for their own repo; non-GitHub origins don't. - assert.equal(githubRepoSlug('git@github.com:Someone/hermes-agent.git'), 'someone/hermes-agent') - assert.equal(githubRepoSlug('https://gitlab.example/x/y.git'), null) - assert.equal( - branchTipApiUrl('nousresearch/hermes-agent', 'bb/gui'), - 'https://api.github.com/repos/nousresearch/hermes-agent/commits/bb%2Fgui' - ) -}) diff --git a/apps/desktop/electron/update-api-check.ts b/apps/desktop/electron/update-api-check.ts deleted file mode 100644 index 10aa82b948..0000000000 --- a/apps/desktop/electron/update-api-check.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Passive update checks against the GitHub REST API instead of git. - * - * Every desktop client used to run `git fetch origin ` (or `ls-remote`) - * twice every 30 minutes, plus on each window focus. Multiplied across the - * install base that is tens of millions of pack negotiations a day against one - * repo — GitHub flagged it. A passive check only needs two facts the API gives - * for free: the remote tip SHA (`GET /repos/{repo}/commits/{branch}` with the - * `application/vnd.github.sha` media type — a 40-byte body) and, when the tips - * differ, the compare endpoint's `ahead_by` + `commits[]`. `git fetch` now - * runs only when the user actually applies an update. - * - * Pure helpers here (URL builders, cache policy, payload mapping) so they are - * unit-testable without booting Electron; the bounded network call is injected. - */ - -import { canonicalGitHubRemote } from './update-remote' - -export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000 -// A failed check (offline, 403 rate-limit) is retried sooner than a good one, -// but never on every poller tick. -export const UPDATE_CHECK_FAILURE_TTL_MS = 60 * 60 * 1000 - -export interface CachedUpdateCheck { - fetchedAt: number - currentSha: string - branch: string - status: Record & { error?: string } -} - -/** `owner/repo` for any GitHub remote form; null for non-GitHub origins. */ -export function githubRepoSlug(originUrl: string): string | null { - const canonical = canonicalGitHubRemote(originUrl) - const match = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical) - - return match ? match[1] : null -} - -export function branchTipApiUrl(slug: string, branch: string): string { - return `https://api.github.com/repos/${slug}/commits/${encodeURIComponent(branch)}` -} - -export function compareApiUrl(slug: string, currentSha: string, targetSha: string): string { - return `https://api.github.com/repos/${slug}/compare/${currentSha}...${targetSha}` -} - -/** - * Whether a cached result still answers a passive check. The cache is keyed on - * the local HEAD and branch: applying an update or switching branches changes - * HEAD and invalidates it immediately, so a 24h TTL never shows a stale - * "update available" after the user just updated. - */ -export function cacheIsFresh( - cached: CachedUpdateCheck | null | undefined, - { branch, currentSha, now }: { branch: string; currentSha: string; now: number } -): boolean { - if (!cached || cached.branch !== branch || cached.currentSha !== currentSha) { - return false - } - - const ttl = cached.status.error ? UPDATE_CHECK_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS - - return now - cached.fetchedAt < ttl -} - -export interface CompareCommit { - sha: string - summary: string - author: string - at: number -} - -/** - * Map the compare payload to the shape the update overlay renders. `ahead_by` - * is how far the remote tip is ahead of local HEAD, i.e. the behind count; 0 - * with differing tips means local carries commits on top of origin (not - * behind). Any shape surprise returns null so callers keep the honest - * "update available, count unknown" state instead of trusting a partial answer. - */ -export function parseCompare(payload: unknown): { behind: number; commits: CompareCommit[] } | null { - if (!payload || typeof payload !== 'object') { - return null - } - - const ahead = (payload as { ahead_by?: unknown }).ahead_by - - if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) { - return null - } - - const raw = (payload as { commits?: unknown }).commits - - const commits: CompareCommit[] = Array.isArray(raw) - ? raw - .map(entry => { - const sha = typeof entry?.sha === 'string' ? entry.sha : '' - const message = typeof entry?.commit?.message === 'string' ? entry.commit.message : '' - const author = typeof entry?.commit?.author?.name === 'string' ? entry.commit.author.name : '' - - const date = - typeof entry?.commit?.committer?.date === 'string' ? Date.parse(entry.commit.committer.date) : NaN - - return { sha, summary: message.split('\n')[0], author, at: Number.isFinite(date) ? date : 0 } - }) - .filter(commit => commit.sha) - // The overlay lists newest first; compare returns oldest first. - .reverse() - : [] - - return { behind: ahead, commits } -} diff --git a/apps/desktop/electron/update-root-policy.test.ts b/apps/desktop/electron/update-root-policy.test.ts deleted file mode 100644 index 3078895bd3..0000000000 --- a/apps/desktop/electron/update-root-policy.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Tests for electron/update-root-policy.ts — the pure classifier that decides - * whether the desktop's git-based self-update may run against a resolved - * update root. A checkout the install contract does not manage (steward-owned - * `external`/`electron-updater` mechanisms) must be refused with a user-action - * pointer instead of the desktop pulling into it. - */ - -import assert from 'node:assert/strict' - -import { test } from 'vitest' - -import { classifyUpdateRoot } from './update-root-policy' - -test('a non-git root is not updatable and carries no user advice', () => { - const result = classifyUpdateRoot({ isGitTree: false, updateMechanism: null }) - - assert.equal(result.updatable, false) - assert.equal(result.verdict, 'not-a-checkout') - assert.equal(result.provenance, 'not-a-checkout') - assert.equal(result.advice, null) - assert.ok(result.message) -}) - -test('a self-managed checkout is updatable', () => { - const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'self' }) - - assert.equal(result.updatable, true) - assert.equal(result.verdict, 'updatable') - assert.equal(result.provenance, 'managed-self') - assert.equal(result.advice, null) - assert.equal(result.message, null) -}) - -test.each(['external', 'electron-updater', 'app-installer'] as const)('a %s-owned checkout is never git-updated by the desktop', updateMechanism => { - const result = classifyUpdateRoot({ isGitTree: true, updateMechanism }) - - assert.equal(result.updatable, false) - assert.equal(result.verdict, 'steward-owned-git-tree') - assert.equal(result.provenance, 'steward-owned') - assert.equal(result.advice, 'git pull') - assert.ok(result.message?.includes(updateMechanism)) -}) - -test('an unstamped git tree (dev checkout) stays updatable with unknown provenance', () => { - const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: null }) - - assert.equal(result.updatable, true) - assert.equal(result.verdict, 'updatable') - assert.equal(result.provenance, 'unknown') - assert.equal(result.advice, null) -}) - -test('the classification is a pure function of its inputs', () => { - const facts = { isGitTree: true, updateMechanism: 'external' as const } - assert.deepEqual(classifyUpdateRoot(facts), classifyUpdateRoot({ ...facts })) -}) diff --git a/apps/desktop/electron/update-root-policy.ts b/apps/desktop/electron/update-root-policy.ts deleted file mode 100644 index f7af06b0b4..0000000000 --- a/apps/desktop/electron/update-root-policy.ts +++ /dev/null @@ -1,94 +0,0 @@ -// update-root-policy.ts — pure classifier for the desktop self-update root. -// -// The desktop's git-based self-update arm runs `git fetch/merge` against a -// checkout it resolved (resolveUpdateRoot in main.ts). That root is only -// legitimate update territory when the install contract says so: -// -// - Not a `.git` tree → there is nothing to pull; the desktop cannot -// self-update here (bundled installs use the OS App Installer arm and -// never reach this classifier). -// - A `.git` tree whose install stamp says `updateMechanism: "self"` → the -// install is a managed self-updating checkout (e.g. the desktop -// bootstrap's clone, which writes exactly that stamp) — updatable. -// - A `.git` tree with any OTHER mechanism (`external`: the store/steward -// owns updates; `electron-updater`: the desktop package does) → the tree -// is managed by someone else. Pulling into it from the desktop would -// stash-and-move a user's checkout out from under its steward. -// - No stamp at all (null mechanism; a dev source checkout) → provenance -// unknown. The classifier reports `updatable` with `provenance: -// 'unknown'`: a developer's working tree keeps its existing update flow, -// and callers log the ambiguity rather than silently widening the refusal. -// -// Pure and dependency-injected (same shape as update-gate.ts) so the policy -// is unit-testable without booting Electron, and every caller gets the same -// answer from one authority. - -import type { InstallStamp } from './install-stamp' - -export type UpdateRootProvenance = 'managed-self' | 'steward-owned' | 'unknown' | 'not-a-checkout' - -export interface UpdateRootClassification { - /** Whether the desktop's git-based self-update may run against this root. */ - updatable: boolean - /** Machine-readable verdict for update-check results and logs. */ - verdict: 'updatable' | 'not-a-checkout' | 'steward-owned-git-tree' | 'unmanaged-git-tree' - /** Why the root is (or is not) updatable, for user-facing messages. */ - message: string | null - /** The command that fixes it, when the user (not the app) must act. */ - advice: 'git pull' | null - provenance: UpdateRootProvenance -} - -export interface UpdateRootFacts { - /** True when the resolved update root contains a `.git` entry. */ - isGitTree: boolean - /** The install stamp's updateMechanism, or null when there is no stamp. */ - updateMechanism: InstallStamp['updateMechanism'] | null -} - -/** - * Classify whether the desktop may self-update (git pull semantics) against - * the resolved update root. - */ -export function classifyUpdateRoot(facts: UpdateRootFacts): UpdateRootClassification { - if (!facts.isGitTree) { - return { - updatable: false, - verdict: 'not-a-checkout', - message: 'This install has no git checkout to update — the app or its steward owns the update loop.', - advice: null, - provenance: 'not-a-checkout' - } - } - - if (facts.updateMechanism === 'self') { - return { - updatable: true, - verdict: 'updatable', - message: null, - advice: null, - provenance: 'managed-self' - } - } - - if (facts.updateMechanism !== null) { - return { - updatable: false, - verdict: 'steward-owned-git-tree', - message: - `This checkout is managed by its install method (${facts.updateMechanism}); ` + - 'the desktop will not run git updates against it.', - advice: 'git pull', - provenance: 'steward-owned' - } - } - - // No stamp: unknown provenance (typically a developer source checkout). - return { - updatable: true, - verdict: 'updatable', - message: null, - advice: null, - provenance: 'unknown' - } -} diff --git a/apps/desktop/electron/updater/checkout-check-live.test.ts b/apps/desktop/electron/updater/checkout-check-live.test.ts deleted file mode 100644 index 40c93f465b..0000000000 --- a/apps/desktop/electron/updater/checkout-check-live.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { execFileSync } from 'node:child_process' -import * as fs from 'node:fs' -import * as http from 'node:http' -import type { AddressInfo } from 'node:net' -import * as os from 'node:os' -import * as path from 'node:path' - -import { expect, it } from 'vitest' - -import { checkCheckoutUpdates, type CheckoutCheckDeps } from './checkout-check' - -it('checks a real linked worktree through HTTP and reuses the disk cache until forced or HEAD changes', async (): Promise => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-worktree-')) - const checkout = path.join(root, 'source') - const worktree = path.join(root, 'worktree') - const requests: string[] = [] - - const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse): void => { - requests.push(request.url ?? '') - response.end(targetSha) - }) - - let targetSha = '' - - function git(args: string[], cwd: string = checkout): string { - return execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', ...args], { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'] - }).trim() - } - - try { - fs.mkdirSync(checkout) - git(['init', '--initial-branch=main']) - git(['commit', '--allow-empty', '-m', 'initial']) - git(['remote', 'add', 'origin', 'https://github.com/example/test.git']) - git(['worktree', 'add', '--detach', worktree]) - targetSha = git(['rev-parse', 'HEAD']) - await new Promise((resolve: () => void): void => { server.listen(0, '127.0.0.1', resolve) }) - const address = server.address() as AddressInfo - - const deps: CheckoutCheckDeps = { - updateCheckCachePath: path.join(root, 'cache.json'), - writeFileAtomic: (filePath: string, contents: string): void => { - fs.writeFileSync(`${filePath}.tmp`, contents) - fs.renameSync(`${filePath}.tmp`, filePath) - }, - isGitCheckout: (directory: string): boolean => fs.existsSync(path.join(directory, '.git')), - readCanonicalInstallStamp: (): null => null, - readDesktopUpdateConfig: (): { branch: string } => ({ branch: 'main' }), - resolveUpdateRoot: (): string => worktree, - resolveHealedBranch: async (_directory: string, branch: string): Promise => branch, - getOriginUrl: async (directory: string): Promise => git(['remote', 'get-url', 'origin'], directory), - runGit: async (args: string[], options?: { cwd?: string }): Promise<{ code: number; stdout: string; stderr: string }> => { - // A passive check must not use git to contact the configured remote. - expect(['rev-parse', 'status']).toContain(args[0]) - - return { code: 0, stdout: git(args, options?.cwd), stderr: '' } - }, - readSourceUpdate: async (): Promise<{ channel: 'main' }> => ({ channel: 'main' }), - fetchGitHubApi: async (url: string, accept?: string): Promise => { - const parsed = new URL(url) - expect(parsed.hostname).toBe('api.github.com') - - const response = await fetch(`http://127.0.0.1:${address.port}${parsed.pathname}`, { - headers: { Accept: accept ?? 'application/json' } - }) - - return response.text() - }, - rememberLog: (message: unknown): void => { throw new Error(String(message)) } - } - - expect(fs.statSync(path.join(worktree, '.git')).isFile()).toBe(true) - expect(await checkCheckoutUpdates(deps)).toMatchObject({ supported: true, currentSha: targetSha, updateAvailable: false }) - expect(requests).toHaveLength(1) - await checkCheckoutUpdates({ ...deps }) - expect(requests).toHaveLength(1) - await checkCheckoutUpdates(deps, { force: true }) - expect(requests).toHaveLength(2) - git(['commit', '--allow-empty', '-m', 'advance'], worktree) - targetSha = git(['rev-parse', 'HEAD'], worktree) - expect(await checkCheckoutUpdates(deps)).toMatchObject({ currentSha: targetSha, updateAvailable: false }) - expect(requests).toHaveLength(3) - } finally { - server.closeAllConnections() - await new Promise((resolve: () => void): void => { server.close((): void => resolve()) }) - fs.rmSync(root, { recursive: true, force: true }) - } -}) diff --git a/apps/desktop/electron/updater/checkout-check.test.ts b/apps/desktop/electron/updater/checkout-check.test.ts deleted file mode 100644 index 2c69f14b3a..0000000000 --- a/apps/desktop/electron/updater/checkout-check.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' - -import { afterEach, expect, it, vi } from 'vitest' - -import { checkCheckoutUpdates, type CheckoutCheckDeps } from './checkout-check' - -const roots: string[] = [] -afterEach((): void => { - for (const root of roots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }) - } -}) - -function fixture(): CheckoutCheckDeps { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'checkout-check-')) - roots.push(root) - - return { - writeFileAtomic: (filePath: string, contents: string): void => fs.writeFileSync(filePath, contents), - updateCheckCachePath: path.join(root, 'cache.json'), - isGitCheckout: (): boolean => true, - readCanonicalInstallStamp: (): null => null, - readDesktopUpdateConfig: (): { branch: string } => ({ branch: 'main' }), - resolveUpdateRoot: (): string => root, - resolveHealedBranch: async (_root: string, branch: string): Promise => branch, - getOriginUrl: async (): Promise => 'git@github.com:NousResearch/hermes-agent.git', - runGit: vi.fn(async (args: string[]): Promise<{ code: number; stdout: string; stderr: string }> => { - const key = args.join(' ') - - if (key === 'rev-parse HEAD') { - return { code: 0, stdout: 'a'.repeat(40), stderr: '' } - } - - if (key === 'rev-parse --abbrev-ref HEAD') { - return { code: 0, stdout: 'main', stderr: '' } - } - - if (key === 'status --porcelain') { - return { code: 0, stdout: '', stderr: '' } - } - - throw new Error(`Unexpected git operation: ${key}`) - }), - readSourceUpdate: async (): Promise<{ channel: 'main' }> => ({ channel: 'main' }), - fetchGitHubApi: vi.fn(async (url: string): Promise => - url.includes('/commits/') ? 'b'.repeat(40) : { ahead_by: 3, commits: [] } - ), - rememberLog: vi.fn() - } -} - -it('uses API checks and a disk cache while a forced check bypasses the cache', async (): Promise => { - const deps = fixture() - const first = await checkCheckoutUpdates(deps) - expect(first.behind).toBe(3) - expect(first.updateAvailable).toBe(true) - expect(deps.fetchGitHubApi).toHaveBeenCalledTimes(2) - expect(await checkCheckoutUpdates(deps)).toEqual(first) - expect(deps.fetchGitHubApi).toHaveBeenCalledTimes(2) - await checkCheckoutUpdates(deps, { force: true }) - expect(deps.fetchGitHubApi).toHaveBeenCalledTimes(4) -}) - -it('follows the current branch unless config explicitly overrides it, including cached checks', async (): Promise => { - const deps: CheckoutCheckDeps = fixture() - const localGit: CheckoutCheckDeps['runGit'] = deps.runGit - let currentBranch: string = 'feature/foo' - let configuredBranch: string = 'main' - let branchExplicit: boolean = false - deps.readDesktopUpdateConfig = (): { branch: string; branchExplicit: boolean } => ({ - branch: configuredBranch, - branchExplicit - }) - deps.runGit = async ( - args: string[], - options?: { cwd?: string } - ): Promise<{ code: number; stdout: string; stderr: string }> => - args.join(' ') === 'rev-parse --abbrev-ref HEAD' - ? { code: 0, stdout: currentBranch, stderr: '' } - : localGit(args, options) - - for (const scenario of [ - { current: 'feature/foo', configured: 'main', explicit: false, target: 'feature/foo' }, - { current: 'feature/foo', configured: 'main', explicit: true, target: 'main' }, - { current: 'feature/foo', configured: 'release/next', explicit: true, target: 'release/next' }, - { current: 'feature/foo', configured: 'main', explicit: false, target: 'feature/foo' }, - { current: 'feature/bar', configured: 'main', explicit: false, target: 'feature/bar' }, - { current: 'HEAD', configured: 'main', explicit: false, target: 'main' }, - { current: '', configured: 'main', explicit: false, target: 'main' } - ]) { - currentBranch = scenario.current - configuredBranch = scenario.configured - branchExplicit = scenario.explicit - vi.mocked(deps.fetchGitHubApi).mockClear() - expect(await checkCheckoutUpdates(deps)).toMatchObject({ - branch: scenario.target, - currentBranch, - targetSha: 'b'.repeat(40) - }) - - if (currentBranch) { - expect(deps.fetchGitHubApi).toHaveBeenCalledWith( - expect.stringContaining(`/commits/${encodeURIComponent(scenario.target)}`), - 'application/vnd.github.sha' - ) - } - - vi.mocked(deps.fetchGitHubApi).mockClear() - expect(await checkCheckoutUpdates(deps)).toMatchObject({ branch: scenario.target, currentBranch }) - expect(deps.fetchGitHubApi).not.toHaveBeenCalled() - } -}) - -it('does not report locally ahead commits as an update', async (): Promise => { - const deps = fixture() - deps.fetchGitHubApi = async (url: string): Promise => - url.includes('/commits/') ? 'b'.repeat(40) : { ahead_by: 0 } - expect(await checkCheckoutUpdates(deps)).toMatchObject({ behind: 0, updateAvailable: false, commits: [] }) -}) - -it('keeps an unknown count when the compare API fails', async (): Promise => { - const deps = fixture() - - deps.fetchGitHubApi = async (url: string): Promise => { - if (url.includes('/commits/')) { - return 'b'.repeat(40) - } - - throw new Error('rate limited') - } - - expect(await checkCheckoutUpdates(deps)).toMatchObject({ behind: null, updateAvailable: true }) -}) - -it('uses only ls-remote for non-GitHub network checks', async (): Promise => { - const deps = fixture() - const localGit = deps.runGit - deps.getOriginUrl = async (): Promise => 'https://git.example/repo.git' - deps.runGit = vi.fn( - async (args: string[], options: { cwd?: string }): Promise<{ code: number; stdout: string; stderr: string }> => { - if (args[0] === 'ls-remote') { - return { code: 0, stdout: `${'b'.repeat(40)}\trefs/heads/main`, stderr: '' } - } - - if (args[0] === 'cat-file') { - return { code: 1, stdout: '', stderr: '' } - } - - return localGit(args, options) - } - ) - expect(await checkCheckoutUpdates(deps)).toMatchObject({ behind: null, updateAvailable: true }) - expect(deps.fetchGitHubApi).not.toHaveBeenCalled() -}) diff --git a/apps/desktop/electron/updater/checkout-check.ts b/apps/desktop/electron/updater/checkout-check.ts deleted file mode 100644 index fc9ef072d1..0000000000 --- a/apps/desktop/electron/updater/checkout-check.ts +++ /dev/null @@ -1,207 +0,0 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' - -import type { InstallStamp } from '../install-stamp' -import { branchTipApiUrl, cacheIsFresh, compareApiUrl, githubRepoSlug, parseCompare } from '../update-api-check' -import { classifyUpdateRoot } from '../update-root-policy' - -import { SOURCE_PROBE_RECOVERY, type SourceUpdate } from './checkout-source' - -import type { UpdaterStatusWire } from './index' - -export interface CheckoutCheckDeps { - writeFileAtomic: (filePath: string, contents: string) => void - updateCheckCachePath: string - isGitCheckout: (root: string) => boolean - readCanonicalInstallStamp: () => { updateMechanism?: InstallStamp['updateMechanism'] } | null - readDesktopUpdateConfig: () => { branch: string; branchExplicit?: boolean } - readSourceUpdate: (root: string) => Promise - resolveUpdateRoot: () => string - resolveHealedBranch: (root: string, branch: string) => Promise - getOriginUrl: (root: string) => Promise - runGit: (args: string[], options?: { cwd?: string }) => Promise<{ code: number; stdout: string; stderr: string }> - fetchGitHubApi: (url: string, accept?: string) => Promise - rememberLog: (chunk: unknown) => void -} - -interface CachedCheckoutCheck { - fetchedAt: number - currentSha: string - branch: string - originUrl: string - updateRoot: string - status: UpdaterStatusWire & Record -} - -function readCache(filePath: string): CachedCheckoutCheck | null { - try { - const cached: CachedCheckoutCheck = JSON.parse(fs.readFileSync(filePath, 'utf8')) - - return cached?.status?.supported === true ? cached : null - } catch { - return null - } -} - -async function checkApi( - deps: CheckoutCheckDeps, - slug: string, - branch: string, - currentSha: string -): Promise> { - let targetSha: string - - try { - targetSha = String(await deps.fetchGitHubApi(branchTipApiUrl(slug, branch), 'application/vnd.github.sha')).trim() - } catch (error: unknown) { - return { error: 'fetch-failed', message: `GitHub API: ${error instanceof Error ? error.message : String(error)}` } - } - - if (!/^[0-9a-f]{40}$/i.test(targetSha)) { - return { error: 'fetch-failed', message: 'GitHub API returned no tip SHA.' } - } - - if (targetSha === currentSha) { - return { behind: 0, updateAvailable: false, targetSha, commits: [] } - } - - const compared = await deps - .fetchGitHubApi(compareApiUrl(slug, currentSha, targetSha)) - .then(parseCompare) - .catch((): null => null) - - return { - behind: compared?.behind ?? null, - updateAvailable: compared?.behind !== 0, - targetSha, - commits: compared?.behind === 0 ? [] : (compared?.commits ?? []) - } -} - -async function checkLsRemote( - deps: CheckoutCheckDeps, - updateRoot: string, - branch: string, - currentSha: string -): Promise> { - const target = await deps.runGit(['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: updateRoot }) - const targetSha = target.stdout.trim().split(/\s+/)[0] || '' - - if (target.code !== 0 || !targetSha) { - return { error: 'fetch-failed', message: target.stderr.split('\n')[0] || 'git ls-remote failed.' } - } - - if (targetSha === currentSha) { - return { behind: 0, updateAvailable: false, targetSha, commits: [] } - } - - const known = (await deps.runGit(['cat-file', '-e', `${targetSha}^{commit}`], { cwd: updateRoot })).code === 0 - - const isAncestor = - known && (await deps.runGit(['merge-base', '--is-ancestor', targetSha, 'HEAD'], { cwd: updateRoot })).code === 0 - - return { behind: isAncestor ? 0 : null, updateAvailable: !isAncestor, targetSha, commits: [] } -} - -/** Passive checks must not fetch packs or mutate a steward-owned checkout. */ -export async function checkCheckoutUpdates( - deps: CheckoutCheckDeps, - { force = false }: { force?: boolean } = {} -): Promise { - const updateRoot: string = deps.resolveUpdateRoot() - const config: ReturnType = deps.readDesktopUpdateConfig() - let branch: string = config.branch - - const policy = classifyUpdateRoot({ - isGitTree: deps.isGitCheckout(updateRoot), - updateMechanism: deps.readCanonicalInstallStamp()?.updateMechanism ?? null - }) - - if (!policy.updatable) { - return { - supported: false, - reason: policy.verdict === 'not-a-checkout' ? 'not-a-git-checkout' : `update-root-${policy.verdict}`, - message: policy.message, - advice: policy.advice, - hermesRoot: updateRoot, - branch - } - } - - const git = async (args: string[]): Promise => (await deps.runGit(args, { cwd: updateRoot })).stdout.trim() - - const [currentSha, dirty, currentBranch, originUrl] = await Promise.all([ - git(['rev-parse', 'HEAD']), - git(['status', '--porcelain']), - git(['rev-parse', '--abbrev-ref', 'HEAD']), - deps.getOriginUrl(updateRoot) - ]) - - if (config.branchExplicit === false && currentBranch && currentBranch !== 'HEAD') { - branch = currentBranch - } - - const selection: SourceUpdate | null = await deps.readSourceUpdate(updateRoot) - - if (selection === null) { - return { supported: false, reason: 'source-probe-unavailable', message: SOURCE_PROBE_RECOVERY, hermesRoot: updateRoot } - } - - if (selection.channel !== 'main') { - return { - supported: true, - ...selection, - channel: selection.channel, - currentSha, - currentBranch, - dirty: dirty.length > 0, - hermesRoot: updateRoot, - fetchedAt: Date.now(), - updateAvailable: !selection.error && selection.targetSha !== currentSha, - behind: selection.targetSha === currentSha ? 0 : null, - commits: [] - } - } - - const cached = readCache(deps.updateCheckCachePath) - const now = Date.now() - - if ( - !force && - cached?.updateRoot === updateRoot && - cached.originUrl === originUrl && - cacheIsFresh(cached, { branch, currentSha, now }) - ) { - return { ...cached.status, dirty: dirty.length > 0, currentBranch } - } - - branch = await deps.resolveHealedBranch(updateRoot, branch) - const slug = githubRepoSlug(originUrl) - - const status = slug - ? await checkApi(deps, slug, branch, currentSha) - : await checkLsRemote(deps, updateRoot, branch, currentSha) - - const result: UpdaterStatusWire & Record = { - supported: true, - branch, - currentBranch, - currentSha, - dirty: dirty.length > 0, - hermesRoot: updateRoot, - fetchedAt: now, - ...status - } - - try { - fs.mkdirSync(path.dirname(deps.updateCheckCachePath), { recursive: true }) - const entry: CachedCheckoutCheck = { fetchedAt: now, currentSha, branch, originUrl, updateRoot, status: result } - deps.writeFileAtomic(deps.updateCheckCachePath, JSON.stringify(entry)) - } catch (error: unknown) { - deps.rememberLog( - `[updates] could not persist check cache: ${error instanceof Error ? error.message : String(error)}` - ) - } - - return result -} diff --git a/apps/desktop/electron/updater/checkout-legacy.test.ts b/apps/desktop/electron/updater/checkout-legacy.test.ts index 2c0977a6ef..1236682ad9 100644 --- a/apps/desktop/electron/updater/checkout-legacy.test.ts +++ b/apps/desktop/electron/updater/checkout-legacy.test.ts @@ -10,7 +10,7 @@ import { readSourceUpdate, type SourceUpdate } from './checkout-source' it('offers manual recovery only for a missing source probe, never for a broken probe', async (): Promise => { const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-channel-')) const home: string = path.join(root, 'profile') - const modulePath: string = path.join(root, 'hermes_cli', 'source_releases.py') + const modulePath: string = path.join(root, 'hermes_cli', 'source_check.py') fs.mkdirSync(path.dirname(modulePath)) fs.mkdirSync(home) fs.writeFileSync(path.join(root, 'hermes_cli', '__init__.py'), '') @@ -20,19 +20,11 @@ it('offers manual recovery only for a missing source probe, never for a broken p }) const deps: CheckoutStrategyDeps = { - isGitCheckout: (): boolean => true, - updateCheckCachePath: path.join(root, 'cache.json'), - writeFileAtomic: vi.fn(), readSourceUpdate: probe, fetchGitHubApi: vi.fn(), + readSourceUpdate: probe, hermesHome: home, isWindows: process.platform === 'win32', isMac: process.platform === 'darwin', defaultUpdateBranch: 'main', updateHandoffDwellMs: 0, - directoryExists: fs.existsSync, readCanonicalInstallStamp: (): null => null, - readDesktopUpdateConfig: (): { branch: string } => ({ branch: 'main' }), + directoryExists: fs.existsSync, resolveUpdateRoot: (): string => root, resolveUpdaterBinary: vi.fn((): string => 'frozen-updater'), - resolveHealedBranch: vi.fn(async (_root: string, branch: string): Promise => branch), - getOriginUrl: async (): Promise => 'https://github.com/fixture/repo', - runGit: async (args: string[]): Promise<{ code: number; stdout: string; stderr: string }> => ({ - code: 0, stdout: args.includes('--abbrev-ref') ? 'feature/work' : args.includes('HEAD') ? 'a'.repeat(40) : '', stderr: '' - }), firstLine: (text: string): string => text.split('\n')[0], emitUpdateProgress: vi.fn(), rememberLog: vi.fn(), startHermes: vi.fn(async (): Promise => {}), stopBackendsForUpdate: vi.fn(async (): Promise => {}), @@ -54,7 +46,6 @@ it('offers manual recovery only for a missing source probe, never for a broken p expect(result.command).not.toContain('--branch') expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(deps.resolveUpdaterBinary).not.toHaveBeenCalled() - expect(deps.fetchGitHubApi).not.toHaveBeenCalled() expect(deps.quit).not.toHaveBeenCalled() } diff --git a/apps/desktop/electron/updater/checkout-ownership.test.ts b/apps/desktop/electron/updater/checkout-ownership.test.ts index 9328b14bc8..dbe124253c 100644 --- a/apps/desktop/electron/updater/checkout-ownership.test.ts +++ b/apps/desktop/electron/updater/checkout-ownership.test.ts @@ -1,62 +1,43 @@ -import { describe, expect, it, vi } from 'vitest' +import { expect, it, vi } from 'vitest' import { type CheckoutStrategyDeps, createCheckoutStrategy } from './checkout' +import type { SourceUpdate } from './checkout-source' -function dependencies(): CheckoutStrategyDeps { - return { - isGitCheckout: (): boolean => true, - updateCheckCachePath: 'unused-cache.json', - writeFileAtomic: vi.fn(), - readSourceUpdate: vi.fn(async (): Promise<{ channel: 'main' }> => ({ channel: 'main' })), - fetchGitHubApi: vi.fn(), - hermesHome: 'home', - isWindows: process.platform === 'win32', - isMac: process.platform === 'darwin', - defaultUpdateBranch: 'main', - updateHandoffDwellMs: 0, - directoryExists: () => true, - readCanonicalInstallStamp: () => ({ updateMechanism: 'external' }), - readDesktopUpdateConfig: () => ({ branch: 'main' }), - resolveUpdateRoot: () => 'repo', - resolveUpdaterBinary: () => null, - resolveHealedBranch: async (_, branch) => branch, - getOriginUrl: async () => '', - runGit: vi.fn(async () => { throw new Error('unexpected git invocation') }), - firstLine: text => text.split('\n')[0], - emitUpdateProgress: vi.fn(), - rememberLog: vi.fn(), - startHermes: vi.fn(async () => {}), - stopBackendsForUpdate: vi.fn(async (): Promise => {}), - repairMacUpdaterHelper: vi.fn(), - preflightStateDb: vi.fn(), - runningAppBundle: () => null, - markQuittingForHandoff: vi.fn(), - quit: vi.fn() - } -} +it.each(['not-a-git-checkout', 'update-root-steward-owned-git-tree', 'fetch-failed'])( + 'preserves the Python refusal or error before handoff: %s', + async (reason: string): Promise => { + const status: SourceUpdate = reason === 'fetch-failed' + ? { supported: true, error: reason } + : { supported: false, reason } -describe('checkout update admission', () => { - it.each(['external', 'app-installer', 'electron-updater'] as const)('refuses %s-owned code without fetching or stopping the backend', async updateMechanism => { - const deps = dependencies() - deps.readCanonicalInstallStamp = () => ({ updateMechanism }) - const strategy = createCheckoutStrategy(deps) - const result = await strategy.check() + const deps: CheckoutStrategyDeps = { + readSourceUpdate: vi.fn(async (): Promise => status), + hermesHome: 'home', + isWindows: process.platform === 'win32', + isMac: process.platform === 'darwin', + defaultUpdateBranch: 'main', + updateHandoffDwellMs: 0, + directoryExists: (): boolean => true, + resolveUpdateRoot: (): string => 'repo', + resolveUpdaterBinary: vi.fn((): null => null), + firstLine: (text: string): string => text.split('\n')[0], + emitUpdateProgress: vi.fn(), + rememberLog: vi.fn(), + startHermes: vi.fn(async (): Promise => {}), + stopBackendsForUpdate: vi.fn(async (): Promise => {}), + repairMacUpdaterHelper: vi.fn(), + preflightStateDb: vi.fn(), + runningAppBundle: (): null => null, + markQuittingForHandoff: vi.fn(), + quit: vi.fn() + } - expect(result.supported).toBe(false) - expect(await strategy.apply()).toMatchObject({ ok: false }) - expect(deps.readSourceUpdate).not.toHaveBeenCalled() - expect(result.mechanism).toBe(strategy.mechanism) - expect(deps.runGit).not.toHaveBeenCalled() + const strategy: ReturnType = createCheckoutStrategy(deps) + expect(await strategy.check()).toMatchObject({ ...status, mechanism: strategy.mechanism }) + expect(await strategy.apply()).toMatchObject({ ok: false, error: reason }) + expect(deps.readSourceUpdate).toHaveBeenLastCalledWith('repo', { force: true }) + expect(deps.resolveUpdaterBinary).not.toHaveBeenCalled() expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(deps.quit).not.toHaveBeenCalled() - }) - - it('rejects a missing source checkout without attempting git', async () => { - const deps = dependencies() - deps.isGitCheckout = (): boolean => false - const result = await createCheckoutStrategy(deps).check() - - expect(result.reason).toBe('not-a-git-checkout') - expect(deps.runGit).not.toHaveBeenCalled() - }) -}) + } +) diff --git a/apps/desktop/electron/updater/checkout-source.test.ts b/apps/desktop/electron/updater/checkout-source.test.ts index b50cee461a..109a7e36e2 100644 --- a/apps/desktop/electron/updater/checkout-source.test.ts +++ b/apps/desktop/electron/updater/checkout-source.test.ts @@ -56,7 +56,7 @@ it('carries each install channel from Python publication checks into the source try { fs.mkdirSync(origin) fs.mkdirSync(home) - git(['init', '-b', 'feature/gui']) + git(['init', '-b', 'upstream-build']) const commits: string[] = [] for (const label of ['old', 'stable', 'canary', 'unpublished']) { @@ -80,7 +80,10 @@ it('carries each install channel from Python publication checks into the source responses.set('/releases/stable/release-candidates.json', { tag: tags.stable, commit: commits[1] }) git(['tag', 'v99.0.0']) - git(['clone', origin, root], temporary) + git(['worktree', 'add', '-b', 'feature/gui', root]) + git(['remote', 'add', 'origin', origin]) + fs.writeFileSync(path.join(origin, 'install-stamp.json'), JSON.stringify({ updateMechanism: 'external' })) + fs.writeFileSync(path.join(root, 'install-stamp.json'), JSON.stringify({ updateMechanism: 'self' })) await new Promise((resolve: () => void): void => { server.listen(0, '127.0.0.1', resolve) }) @@ -122,48 +125,22 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ ) } + const checkerPath: string = path.join(root, 'hermes_cli', 'source_check.py') + fs.writeFileSync(checkerPath, fs.readFileSync(checkerPath, 'utf8').replace( + 'from __future__ import annotations', + `from __future__ import annotations\nimport runpy; runpy.run_path(${JSON.stringify(path.join(root, 'transport.py'))})` + )) + const deps: CheckoutStrategyDeps = { hermesHome: home, isWindows: process.platform === 'win32', isMac: process.platform === 'darwin', defaultUpdateBranch: 'main', updateHandoffDwellMs: 0, - updateCheckCachePath: path.join(home, 'cache.json'), - writeFileAtomic: (file: string, contents: string): void => fs.writeFileSync(file, contents), - isGitCheckout: (): boolean => true, - readCanonicalInstallStamp: (): null => null, - readDesktopUpdateConfig: (): { branch: string; branchExplicit: boolean } => ({ - branch: 'main', - branchExplicit: false - }), resolveUpdateRoot: (): string => root, - readSourceUpdate: async (install: string): Promise => { - const result: { stdout: string } = await execute( - python, - [ - '-c', - "import runpy,sys; runpy.run_path(sys.argv.pop(1)); runpy.run_module('hermes_cli.source_releases', run_name='__main__')", - path.join(root, 'transport.py'), - '--install-root', - install, - '--git', - 'git' - ], - { cwd: root, env: environment } - ) - - return JSON.parse(result.stdout) as SourceUpdate - }, - resolveHealedBranch: async (_root: string, branch: string): Promise => branch, - getOriginUrl: async (): Promise => origin, - runGit: async (args: string[]): Promise<{ code: number; stdout: string; stderr: string }> => ({ - code: 0, - stdout: git(args, root), - stderr: '' + readSourceUpdate: (install: string, opts: { force?: boolean }): Promise => readSourceUpdate({ + python, git: 'git', updateRoot: install, hermesHome: home, force: opts.force }), - fetchGitHubApi: async (): Promise => { - throw new Error('branch API must not resolve releases') - }, directoryExists: fs.existsSync, resolveUpdaterBinary: (): null => null, firstLine: (text: string): string => text.split('\n')[0], @@ -199,7 +176,6 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ for (const channel of ['stable', 'canary'] as const) { await setChannel(channel) - await setChannel(channel === 'stable' ? 'canary' : 'stable', origin) const sha: string = commits[channel === 'stable' ? 1 : 2] const checked: unknown = await strategy.check() expect(checked, JSON.stringify({ checked, requests })).toMatchObject({ @@ -246,8 +222,8 @@ import urllib.request\nfrom urllib.parse import urlsplit\noriginal = urllib.requ expect(deps.stopBackendsForUpdate).not.toHaveBeenCalled() expect(spawned).toHaveLength(0) await setChannel('main') - expect(await readSourceUpdate({ python, git: 'git', updateRoot: root, hermesHome: home })).toEqual({ - channel: 'main' + expect(await readSourceUpdate({ python, git: 'git', updateRoot: root, hermesHome: home })).toMatchObject({ + supported: true, branch: 'feature/gui', targetSha: commits[3], updateAvailable: false }) const count: number = requests.length expect(await strategy.check()).toMatchObject({ diff --git a/apps/desktop/electron/updater/checkout-source.ts b/apps/desktop/electron/updater/checkout-source.ts index cabcf4c470..68d140fc6d 100644 --- a/apps/desktop/electron/updater/checkout-source.ts +++ b/apps/desktop/electron/updater/checkout-source.ts @@ -4,19 +4,20 @@ import { promisify } from 'node:util' import { buildDesktopBackendEnv } from '../backend-env' import { hiddenWindowsChildOptions } from '../windows-child-options' -export interface SourceUpdate { - channel: 'main' | 'stable' | 'canary' - latestTag?: string - targetSha?: string - error?: string - message?: string -} +import type { UpdaterStatusWire } from './index' + +export interface SourceUpdate extends UpdaterStatusWire {} export interface SourceUpdateProbe { python: string | null git: string updateRoot: string hermesHome: string + branch?: string + channel?: 'main' | 'stable' | 'canary' + force?: boolean + cachePath?: string + branchConfigPath?: string } const execute: typeof execFile.__promisify__ = promisify(execFile) @@ -48,8 +49,13 @@ export async function readSourceUpdate(probe: SourceUpdateProbe): Promise string + readSourceUpdate: (root: string, opts: { force?: boolean }) => Promise hermesHome: string isWindows: boolean isMac: boolean @@ -68,7 +69,12 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat const mechanism: UpdaterMechanism = deps.isWindows ? 'windows-handoff' : 'posix-handoff' async function check(opts: { force?: boolean } = {}): Promise { - const status = await checkCheckoutUpdates(deps, opts) + const root: string = deps.resolveUpdateRoot() + + const status: UpdaterStatusWire = await deps.readSourceUpdate(root, opts) ?? { + supported: false, reason: 'source-probe-unavailable', message: SOURCE_PROBE_RECOVERY, hermesRoot: root + } + status.mechanism = mechanism return status @@ -84,7 +90,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat return { mechanism, check, apply } async function applyBody(): Promise { - const status: UpdaterStatusWire = await checkCheckoutUpdates(deps, { force: true }) + const status: UpdaterStatusWire = await check({ force: true }) if (status.reason === 'source-probe-unavailable') { return { ok: true, manual: true, command: 'hermes update --help', message: status.message, hermesRoot: status.hermesRoot } diff --git a/evals/cli_deferred_notice.py b/evals/cli_deferred_notice.py index 595e51b6ad..aa6aeb632e 100644 --- a/evals/cli_deferred_notice.py +++ b/evals/cli_deferred_notice.py @@ -29,11 +29,9 @@ def run_case(root, output, name, behind, early=False, cancel=False): " base_url: http://127.0.0.1:9/v1\n" "display:\n interface: cli\n skip_banner: false\n" "memory:\n provider: ''\n", encoding="utf-8") - cache = hh / ".update_check" - # The version comes from the checkout, not an invented cache identity. - from hermes_cli.banner import VERSION - payload = json.dumps({"ts": time.time(), "behind": behind, - "rev": None, "ver": VERSION}).encode() + cache = hh / "notice-result.json" + # This harness tests display timing, not source-check cache policy. + payload = json.dumps({"behind": behind}).encode() if early: cache.write_bytes(payload) else: @@ -44,7 +42,9 @@ def run_case(root, output, name, behind, early=False, cancel=False): "OPENAI_API_KEY": "local-not-used", "PROMPT_TOOLKIT_NO_CPR": "1"} master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) - bootstrap = ("import hermes_cli.main as m; import hermes_cli.banner as b; " + bootstrap = ("import json; from pathlib import Path; import hermes_cli.main as m; " + "import hermes_cli.banner as b; from hermes_cli import source_check; " + f"source_check.check_for_updates = lambda **kw: json.loads(Path({str(cache)!r}).read_text()); " "print('LOADED', m.__file__, b.__file__, flush=True); m.main()") proc = subprocess.Popen([sys.executable, "-c", bootstrap, "chat"], cwd=root, env=env, stdin=slave, stdout=slave, stderr=slave, diff --git a/evals/update_check_ssh_pty.py b/evals/update_check_ssh_pty.py index fac6b7d90a..c21696378c 100644 --- a/evals/update_check_ssh_pty.py +++ b/evals/update_check_ssh_pty.py @@ -20,7 +20,7 @@ env = {'PATH': f'{BASE}/bin:/usr/bin:/bin', 'HOME': str(BASE/'home'), for name in ('home', 'hermes', 'bin'): (BASE/name).mkdir(exist_ok=True) sys.path.insert(0, str(REPO)) -from hermes_cli import banner +from hermes_cli import source_check if len(sys.argv) > 1: os.environ.clear() @@ -28,7 +28,7 @@ if len(sys.argv) > 1: case = json.loads((BASE/'case.json').read_text()) os.environ.update(case['env']) start = time.monotonic() - result = banner._check_via_local_git(BASE/'checkout') + result = source_check.check_for_updates(install_root=BASE/'checkout').get('behind') print('PRODUCTION_RETURN '+json.dumps({'result': result, 'elapsed': time.monotonic()-start}), flush=True) time.sleep(2) sys.exit(0) @@ -146,7 +146,7 @@ finally: os.killpg(proc.pid, signal.SIGTERM) proc.wait(timeout=5) log.close() -result = {'platform': sys.platform, 'production': banner.__file__, 'rows': rows, +result = {'platform': sys.platform, 'production': source_check.__file__, 'rows': rows, 'isolation': 'PATH ssh adapter only adds -F fixture config; execs real /usr/bin/ssh; loopback sshd and disposable git repos', 'server_stopped': server.poll() is not None, 'agent_stopped': agent is None or agent.poll() is not None} (BASE/'result.json').write_text(json.dumps(result, indent=2)) diff --git a/hermes_cli/_startup_fast.py b/hermes_cli/_startup_fast.py index 16368133f0..6c90573005 100644 --- a/hermes_cli/_startup_fast.py +++ b/hermes_cli/_startup_fast.py @@ -175,10 +175,10 @@ def print_fast_version_info(*, check_updates: bool = True) -> None: # Synchronous update status — bounded by check_for_updates' own subprocess/network timeouts # and its 6-hour cache; any failure prints nothing. try: - from hermes_cli.banner import UPDATE_AVAILABLE_NO_COUNT, check_for_updates + from hermes_cli.source_check import UPDATE_AVAILABLE_NO_COUNT, check_for_updates from hermes_cli.config import recommended_update_command - behind = check_for_updates(passive=True) + behind = check_for_updates(passive=True).get("behind") if behind == UPDATE_AVAILABLE_NO_COUNT: print(f"Update available — run '{recommended_update_command()}'") elif behind and behind > 0: diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 49a7c82be6..f181eaa2bb 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -3,11 +3,11 @@ import json import logging import os import shutil -import subprocess import threading -import time from pathlib import Path -from urllib.parse import urlparse +from hermes_cli import source_check +# Historical updater import (tests/compat/old_updater_surface.json). In-tree callers use the owner. +from hermes_cli.source_check import _github_compare_behind from hermes_constants import get_hermes_home from typing import TYPE_CHECKING, Any, Dict, List, Optional @@ -140,321 +140,6 @@ def get_available_skills() -> Dict[str, List[str]]: return {} if result is _UNCACHED else result -# === Update check === - -# Passive checks hit GitHub at most once a day per install; a failed check retries after an hour -# so a flaky line can't turn every startup into a request (nor stay wrong for a day). -_UPDATE_CHECK_CACHE_SECONDS = 24 * 3600 -_UPDATE_CHECK_FAILURE_CACHE_SECONDS = 3600 -# Upstream tip seen by the most recent check; recorded in the cache file for the changelog. -_last_target_rev: Optional[str] = None - -# Returned when an update is known to exist but commits can't be counted (e.g. nix builds). -UPDATE_AVAILABLE_NO_COUNT = -1 - -_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" -_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent" - - -def _canonical_github_remote(url: str | None) -> str: - """Return ``host/owner/repo`` for common GitHub remote URL forms.""" - if not url: - return "" - value = url.strip() - for ssh_prefix in ("git@github.com:", "ssh://git@github.com/"): - if value.startswith(ssh_prefix): - value = "github.com/" + value[len(ssh_prefix):] - break - else: - parsed = urlparse(value) - if parsed.netloc and parsed.path: - value = f"{parsed.netloc}{parsed.path}" - return value.strip().rstrip("/").removesuffix(".git").lower() - - -def _is_official_ssh_remote(url: str | None) -> bool: - return bool(url) and url.strip().lower().startswith(("git@", "ssh://")) and ( - _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL) - - -_GIT_TEXT_KW = {"text": True, "encoding": "utf-8", "errors": "replace"} - - -def _git_run(args: list[str], *, cwd: Optional[Path] = None, timeout: int = 5, text: bool = True, - network: bool = False): - """Run ``git `` with the shared subprocess boilerplate; None on any exception. - - git output is UTF-8; on Windows ``text=True`` defaults to the ANSI code page and a byte like the - 3rd of 🐛 in a commit subject crashes the stdlib reader thread (#52649), hence the explicit - encoding. ``network=True`` (ls-remote/fetch) detaches stdin and disables git/GCM prompts so a - passive update check can never hang on a ``Username for 'https://github.com':`` prompt. - """ - from hermes_cli._subprocess_compat import noninteractive_git_env, windows_hide_flags - - # The banner/update probes run from GUI-hosted backends too (desktop-spawned - # ``hermes serve``), where a bare git child flashes a console window. - kwargs: dict = {"creationflags": windows_hide_flags()} - if network: - kwargs.update({"stdin": subprocess.DEVNULL, "env": noninteractive_git_env()}) - try: - return subprocess.run( - ["git", *args], capture_output=True, timeout=timeout, cwd=str(cwd) if cwd is not None else None, - **(_GIT_TEXT_KW if text else {}), **kwargs) - except Exception: - return None - - -def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5, network: bool = False) -> Optional[str]: - result = _git_run(args, cwd=cwd, timeout=timeout, network=network) - if result is None or result.returncode != 0: - return None - return (result.stdout or "").strip() - - -def _git_ok(args: list[str], **kw) -> bool: - """True when ``git `` ran and exited 0 (output discarded).""" - result = _git_run(args, text=False, **kw) - return result is not None and result.returncode == 0 - - -def _git_count(args: list[str], *, cwd: Path) -> Optional[int]: - """``int`` of a successful ``git rev-list --count``-style command, else None.""" - result = _git_run(args, cwd=cwd) - if result is not None and result.returncode == 0: - return _quiet(lambda: int(result.stdout.strip())) - return None - - -def _is_full_sha(value: Optional[str]) -> bool: - return isinstance(value, str) and len(value) == 40 and all(c in "0123456789abcdefABCDEF" for c in value) - - -_compare_payload_cache: Dict[tuple, dict] = {} - - -def _github_compare(current_rev: str, target_rev: str) -> Optional[dict]: - """Compare payload for ``current...target`` from the GitHub API; memoized per process. - - Shallow installer clones and API-only probes know the two tip SHAs but have no local history - to run ``rev-list --count`` or ``git log`` across; the payload carries both the count - (``ahead_by``) and the commit list the dashboard/desktop render as "what's changed". - """ - if not (_is_full_sha(current_rev) and _is_full_sha(target_rev)): - return None - key = (current_rev, target_rev) - if key in _compare_payload_cache: - return _compare_payload_cache[key] - url = f"https://api.github.com/repos/nousresearch/hermes-agent/compare/{current_rev}...{target_rev}" - - def _fetch(): - import urllib.request - # api.github.com 403s requests without a User-Agent. - req = urllib.request.Request( - url, headers={"Accept": "application/vnd.github+json", "User-Agent": "hermes-cli-update-check"}) - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read().decode("utf-8")) - payload = _quiet(_fetch) - if not isinstance(payload, dict): - return None - _compare_payload_cache[key] = payload - return payload - - -def _github_compare_behind(current_rev: str, target_rev: str) -> Optional[int]: - """Exact behind-count via the GitHub compare API for uncountable graphs.""" - payload = _github_compare(current_rev, target_rev) - ahead = payload.get("ahead_by") if payload else None - return ahead if isinstance(ahead, int) and not isinstance(ahead, bool) and ahead >= 0 else None - - -def upstream_commits_behind(n: int = 20) -> List[Dict[str, Any]]: - """Commits between the last checked HEAD and upstream tip, newest first; [] when unknown. - - Reads the tips recorded by ``check_for_updates`` so it costs no extra request when the - compare payload is already memoized for this process. - """ - cached = _read_json(get_hermes_home() / ".update_check") or {} - head_rev, target_rev = cached.get("head"), cached.get("target") - if not head_rev or not target_rev or head_rev == target_rev: - return [] - payload = _github_compare(head_rev, target_rev) - rows: List[Dict[str, Any]] = [] - for entry in (payload or {}).get("commits", []) if isinstance(payload, dict) else []: - commit = entry.get("commit") or {} - when = ((commit.get("committer") or {}).get("date") or "") - try: - from datetime import datetime - at = int(datetime.fromisoformat(when.replace("Z", "+00:00")).timestamp()) if when else 0 - except ValueError: - at = 0 - rows.append({ - "sha": str(entry.get("sha", ""))[:7], - "summary": str(commit.get("message", "")).split("\n", 1)[0], - "author": str((commit.get("author") or {}).get("name", "")), - "at": at, - }) - rows.reverse() # compare returns oldest first - return rows[:n] - - -def _tips_behind(head_rev: Optional[str], target_rev: Optional[str], repo_dir: Optional[Path] = None) -> Optional[int]: - """Behind-count from two tip SHAs: None if either is unknown, 0 when equal, else count/sentinel. - - With ``repo_dir``, a target that is already an ancestor of HEAD (local-ahead checkout) is 0 too. - ``ahead_by == 0`` with differing tips means the remote tip is reachable from our HEAD — NOT - behind. A local-only HEAD 404s on the API, which degrades to ``UPDATE_AVAILABLE_NO_COUNT`` — - never a fabricated 1. - """ - if not head_rev or not target_rev: - return None - if head_rev == target_rev or (repo_dir is not None and _git_ok( - ["merge-base", "--is-ancestor", target_rev, "HEAD"], cwd=repo_dir)): - return 0 - counted = _github_compare_behind(head_rev, target_rev) - return counted if counted is not None else UPDATE_AVAILABLE_NO_COUNT - - -def _github_branch_tip(repo_slug: str, branch: str) -> Optional[str]: - """Tip SHA of ``branch`` on GitHub via the REST API (40-byte body, no git, no auth).""" - from urllib.parse import quote - - url = f"https://api.github.com/repos/{repo_slug}/commits/{quote(branch, safe='')}" - - def _fetch(): - import urllib.request - req = urllib.request.Request( - url, headers={"Accept": "application/vnd.github.sha", "User-Agent": "hermes-cli-update-check"}) - with urllib.request.urlopen(req, timeout=10) as resp: - return resp.read().decode("utf-8").strip() - sha = _quiet(_fetch) - return sha if _is_full_sha(sha) else None - - -def _upstream_main_sha() -> Optional[str]: - """Tip SHA of upstream main; API first, HTTPS ``ls-remote`` (no auth, no prompts) as fallback.""" - sha = _github_branch_tip(_OFFICIAL_REPO_CANONICAL.removeprefix("github.com/"), "main") - if sha: - return sha - result = _git_run(["ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"], timeout=10, network=True) - if result is None or result.returncode != 0 or not result.stdout: - return None - return result.stdout.split()[0] or None - - -def _check_via_rev(local_rev: str) -> Optional[int]: - """Compare an embedded git revision to upstream main via the API (see ``_tips_behind``).""" - global _last_target_rev - _last_target_rev = _upstream_main_sha() - return _tips_behind(local_rev, _last_target_rev) - - -def _check_via_local_git(repo_dir: Path) -> Optional[int]: - """Count commits behind origin/main in a local checkout. - - Passive checks never run ``git fetch``: every CLI/TUI/gateway start used to negotiate a pack - with GitHub, and across the install base that was tens of millions of fetch requests a day - (GitHub asked us to poll the API instead). Two tip SHAs are enough — the remote one from the - API, the local one from ``rev-parse`` — and ``_tips_behind`` recovers the exact count through - the compare API when they differ. ``git fetch`` happens only inside ``hermes update``. - """ - # Probe the origin URL under the config-isolated env: a global url..insteadOf rewrite - # otherwise makes an SSH origin masquerade as HTTPS (#104591). - origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir, network=True) - head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) - if not head_rev: - return None - canonical = _canonical_github_remote(origin_url) - if canonical.startswith("github.com/"): - target_rev = _github_branch_tip(canonical.removeprefix("github.com/"), "main") - else: - # Non-GitHub origin: one ls-remote for the tip (ref advertisement only, no pack transfer). - result = _git_run(["ls-remote", "origin", "refs/heads/main"], cwd=repo_dir, timeout=10, network=True) - target_rev = result.stdout.split()[0] if result is not None and result.returncode == 0 and result.stdout else None - global _last_target_rev - _last_target_rev = target_rev - # Tip SHAs alone can't distinguish "behind" from a local commit AHEAD of origin/main, and - # misreporting an ahead checkout nudges the user into `hermes update`, which can wipe carried - # work — hence the ancestor check inside _tips_behind, against the FRESH upstream SHA. - return _tips_behind(head_rev, target_rev, repo_dir) - - -def _read_json(path: Path) -> Optional[dict]: - """Parse ``path`` as a JSON object; None when missing, unreadable, or not a dict.""" - blob = _quiet(lambda: json.loads(path.read_text(encoding="utf-8"))) - return blob if isinstance(blob, dict) else None - - -def check_for_updates(*, passive: bool = False) -> Optional[int]: - """Check whether a Hermes update is available. - - If ``HERMES_REVISION`` is set (nix builds embed it), compare it to upstream main; otherwise - compare the local checkout's HEAD. Both go through the GitHub API, never ``git fetch``. - """ - from hermes_cli.config import get_project_root - from hermes_cli.update_contract import is_commit_build - from hermes_cli.steward import is_bundled_payload - - if is_commit_build(get_project_root()) or is_bundled_payload(get_project_root()): - return None - - def _read_config_opt_out(): - from hermes_cli.config import load_config - return load_config().get("updates", {}).get("check", True) is False - - if passive and _quiet(_read_config_opt_out) is True: - return None - - cache_file = get_hermes_home() / ".update_check" - embedded_rev = os.environ.get("HERMES_REVISION") or None - # Docker images have no working tree (the image excludes `.git`) and set no HERMES_REVISION. - # None makes both the Rich banner and the Ink badge show nothing, mirroring the dashboard's - # `/api/hermes/update/check` short-circuit so the surfaces agree. - def _install_method(): - from hermes_cli.config import detect_install_method, get_project_root - return detect_install_method(get_project_root()) - - if _quiet(_install_method) in {"docker", "apt"}: - return None - from hermes_cli.config import load_config - from hermes_cli.update_channel import resolve_update_channel - - channel = resolve_update_channel(_quiet(load_config), get_project_root()) - # Cache is invalidated when the embedded rev OR installed version changed since the last check. - # For a git checkout the local HEAD is part of the key too: `hermes update` moves HEAD, and a - # stale "3 behind" must not survive the update it just prompted. - now = time.time() - repo_dir = None if embedded_rev else _resolve_repo_dir() - head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) if repo_dir is not None else None - cached = _read_json(cache_file) - if cached is not None and cached.get("rev") == embedded_rev and cached.get("ver") == VERSION \ - and cached.get("head") == head_rev and cached.get("channel", "main") == channel: - ttl = _UPDATE_CHECK_CACHE_SECONDS if cached.get("behind") is not None else _UPDATE_CHECK_FAILURE_CACHE_SECONDS - if now - cached.get("ts", 0) < ttl: - return cached.get("behind") - if channel in {"stable", "canary"}: - from hermes_cli.source_releases import resolve_source_release, source_repository - - global _last_target_rev - repository = source_repository(["git"] if repo_dir else None, repo_dir) - _, _last_target_rev = resolve_source_release(channel, repository=repository) - current = head_rev or embedded_rev - # A selected release can be an ancestor after a channel switch. - # Equality, not ancestry, means this install runs that release. - behind = None if not current or not _last_target_rev else ( - 0 if current == _last_target_rev else UPDATE_AVAILABLE_NO_COUNT - ) - elif embedded_rev: - behind = _check_via_rev(embedded_rev) - else: - # No checkout and no embedded revision — status can't be determined. - behind = _check_via_local_git(repo_dir) if repo_dir is not None else None - _quiet(lambda: cache_file.write_text( - json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION, - "head": head_rev or embedded_rev, "target": _last_target_rev, "channel": channel}), - encoding="utf-8")) - return behind - - def _resolve_repo_dir() -> Optional[Path]: """The active Hermes git checkout, or None if this isn't a git install. @@ -491,11 +176,11 @@ def _compute_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict] repo_dir = repo_dir or _resolve_repo_dir() if repo_dir is None: return _baked_banner_state() - upstream, local = (_git_stdout(["rev-parse", "--short=8", rev], cwd=repo_dir) for rev in ("origin/main", "HEAD")) + upstream, local = (source_check._git_stdout(["rev-parse", "--short=8", rev], cwd=repo_dir) for rev in ("origin/main", "HEAD")) if not upstream or not local: # Live-git lookup failed (e.g. shallow clone without origin/main). return _baked_banner_state() - ahead = _git_count(["rev-list", "--count", "origin/main..HEAD"], cwd=repo_dir) or 0 + ahead = source_check._git_count(["rev-list", "--count", "origin/main..HEAD"], cwd=repo_dir) or 0 return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} @@ -509,7 +194,7 @@ def get_latest_release_tag(repo_dir: Optional[Path] = None) -> Optional[tuple]: """ def _compute(): rd = repo_dir or _resolve_repo_dir() - tag = _git_stdout(["describe", "--tags", "--abbrev=0"], cwd=rd, timeout=3) if rd else None + tag = source_check._git_stdout(["describe", "--tags", "--abbrev=0"], cwd=rd, timeout=3) if rd else None return (tag, f"{_RELEASE_URL_BASE}/{tag}") if tag else None return _memo("_latest_release_cache", _compute) @@ -537,7 +222,7 @@ def format_banner_version_label() -> str: channel = resolve_update_channel(_quiet(load_config), get_project_root()) if channel in {"stable", "canary"}: - head = _git_stdout(["rev-parse", "HEAD"], cwd=get_project_root()) + head = source_check._git_stdout(["rev-parse", "HEAD"], cwd=get_project_root()) return f"{base} · {channel}" + (f" · local {head[:12]}" if head else "") state = get_git_banner_state() if not state: @@ -564,7 +249,7 @@ def prefetch_update_check(): """Kick off update check in a background daemon thread.""" def _run(): global _update_result - _update_result = check_for_updates(passive=True) + _update_result = source_check.check_for_updates(passive=True).get("behind") _update_check_done.set() _daemon(None, _run) @@ -704,8 +389,8 @@ def banner_snapshot_fingerprint() -> Optional[str]: def load_banner_snapshot(enabled_toolsets: List[str] = None) -> Optional[Dict[str, Any]]: """Return the stored banner snapshot when its fingerprint is current.""" - blob = _read_json(_banner_snapshot_path()) - if blob is None: + blob = _quiet(lambda: json.loads(_banner_snapshot_path().read_text(encoding="utf-8"))) + if not isinstance(blob, dict): return None fp = banner_snapshot_fingerprint() if (not fp or blob.get("fingerprint") != fp diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 6a0d47f518..ea65d1adee 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -131,7 +131,7 @@ _DEFAULT_EXPORT_EXCLUDE_ROOT = DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({ "gateway.pid", "gateway_state.json", "processes.json", "auth.json", # API keys, OAuth tokens, credential pools ".env", # API keys (dotenv) - "auth.lock", "active_profile", ".update_check", + "auth.lock", "active_profile", ".update_check", "source-checks", "errors.log", ".hermes_history", # Caches (regenerated on use) diff --git a/hermes_cli/source_check.py b/hermes_cli/source_check.py new file mode 100644 index 0000000000..b7c484d4f0 --- /dev/null +++ b/hermes_cli/source_check.py @@ -0,0 +1,279 @@ +"""Passive source update decisions for one exact installation and profile. + +No fetch, lock repair, or Git writes. Desktop, CLI, and dashboard share this owner. +""" +from __future__ import annotations + +import json +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Optional +from urllib.parse import quote +import urllib.request + +from hermes_constants import get_hermes_home +from hermes_cli.source_releases import OFFICIAL_REPOSITORY, _GITHUB_ORIGIN, resolve_source_release + +logger = logging.getLogger(__name__) +UPDATE_AVAILABLE_NO_COUNT = -1 +_UPDATE_CHECK_CACHE_SECONDS = 24 * 3600 +_UPDATE_CHECK_FAILURE_CACHE_SECONDS = 3600 + + +def _quiet(fn, default=None): + try: + return fn() + except Exception: + return default + + +def source_git_env() -> dict[str, str]: + """Keep read-only Git probes in their explicit cwd, not an inherited worktree.""" + from hermes_cli._subprocess_compat import noninteractive_git_env + + env = noninteractive_git_env() + for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_SHALLOW_FILE", "GIT_NAMESPACE"): + env.pop(key, None) + env["GIT_OPTIONAL_LOCKS"] = "0" + return env + + +_GIT_TEXT_KW = {"text": True, "encoding": "utf-8", "errors": "replace"} + + +def _git_run(args: list[str], *, cwd: Optional[Path] = None, timeout: int = 5, text: bool = True, + git: str = "git"): + """Read Git state without prompts, optional index writes, or inherited targeting.""" + from hermes_cli._subprocess_compat import windows_hide_flags + + kwargs: dict = {"creationflags": windows_hide_flags(), "env": source_git_env(), "stdin": subprocess.DEVNULL} + try: + return subprocess.run( + [git, *args], capture_output=True, timeout=timeout, cwd=str(cwd) if cwd is not None else None, + **(_GIT_TEXT_KW if text else {}), **kwargs) + except Exception: + return None + + +def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5, git: str = "git") -> Optional[str]: + result = _git_run(args, cwd=cwd, timeout=timeout, git=git) + if result is None or result.returncode != 0: + return None + return (result.stdout or "").strip() + + +def _git_ok(args: list[str], **kw) -> bool: + """True when ``git `` ran and exited 0 (output discarded).""" + result = _git_run(args, text=False, **kw) + return result is not None and result.returncode == 0 + + +def _git_count(args: list[str], *, cwd: Path) -> Optional[int]: + """``int`` of a successful ``git rev-list --count``-style command, else None.""" + result = _git_run(args, cwd=cwd) + if result is not None and result.returncode == 0: + return _quiet(lambda: int(result.stdout.strip())) + return None + + +def _is_full_sha(value: Optional[str]) -> bool: + return isinstance(value, str) and len(value) == 40 and all(c in "0123456789abcdefABCDEF" for c in value) + + +def _github_compare(current_rev: str, target_rev: str, repository: str = OFFICIAL_REPOSITORY) -> Optional[dict]: + # Do not memoize this separately: force must bypass failed AND successful network results. + if not (_is_full_sha(current_rev) and _is_full_sha(target_rev)): + return None + payload = _quiet(lambda: json.loads(_request( + f"https://api.github.com/repos/{repository}/compare/{current_rev}...{target_rev}"))) + return payload if isinstance(payload, dict) else None + + +def _github_compare_behind(current_rev: str, target_rev: str, repository: str = OFFICIAL_REPOSITORY) -> Optional[int]: + payload = _github_compare(current_rev, target_rev, repository) + ahead = payload.get("ahead_by") if payload else None + return ahead if isinstance(ahead, int) and not isinstance(ahead, bool) and ahead >= 0 else None + + +def _request(url: str, accept: str = "application/vnd.github+json") -> str: + req = urllib.request.Request(url, headers={"Accept": accept, "User-Agent": "hermes-update-check"}) + with urllib.request.urlopen(req, timeout=10) as response: + return response.read(2 * 1024 * 1024).decode("utf-8").strip() + + +def _branch_tip(repository: str | None, branch: str, root: Path, git: str, remote: str = "origin") -> tuple[str | None, bool]: + # A successful empty ref advertisement alone proves a branch was deleted. + # GitHub 404 can also mean a private repository: it must not heal a branch. + if repository: + sha = _quiet(lambda: _request( + f"https://api.github.com/repos/{repository}/commits/{quote(branch, safe='')}", + "application/vnd.github.sha")) + if _is_full_sha(sha): + return sha, False + if branch == "main" and remote == "origin": + return None, False + result = _git_run(["ls-remote", "--exit-code", "--heads", remote, f"refs/heads/{branch}"], + cwd=root, git=git, timeout=10) + if result is None: + return None, False + sha = result.stdout.split()[0] if result.returncode == 0 and result.stdout else None + return (sha if _is_full_sha(sha) else None), result.returncode == 2 + + +def _commits(payload: dict | None) -> list[dict]: + from datetime import datetime + rows = [] + entries = (payload or {}).get("commits", []) + for entry in entries if isinstance(entries, list) else []: + if not isinstance(entry, dict) or not isinstance(entry.get("sha"), str): + continue + commit = entry.get("commit") or {} + when = (commit.get("committer") or {}).get("date") or "" + at = _quiet(lambda: int(datetime.fromisoformat(when.replace("Z", "+00:00")).timestamp() * 1000), 0) + rows.append({"sha": entry["sha"], "summary": str(commit.get("message", "")).split("\n", 1)[0], + "author": str((commit.get("author") or {}).get("name", "")), "at": at}) + return rows[::-1] + + +def check_for_updates(*, install_root: Path | None = None, home: Path | None = None, + branch: str | None = None, channel: str | None = None, + cache_path: Path | None = None, branch_config_path: Path | None = None, + force: bool = False, + passive: bool = False, git: str = "git") -> dict: + """Return a presentation-ready status. Omitted branch follows the current checkout. + + Only the default (running installation) may use HERMES_REVISION. An explicit + target must never inherit the host process's embedded revision or stamp. + """ + from hermes_cli.config import detect_install_method, get_project_root, require_readable_config_before_write + from hermes_cli.steward import read_install_stamp + from hermes_cli.update_channel import install_id, resolve_update_channel + from hermes_cli.update_contract import COMMIT_BUILD_UPDATE_MESSAGE + + embedded = (os.environ.get("HERMES_REVISION") or None) if install_root is None else None + root = Path(install_root if install_root is not None else get_project_root()).resolve() + home = Path(home if home is not None else get_hermes_home()).resolve() + stamp = read_install_stamp(root) + result = {"supported": False, "hermesRoot": str(root), "behind": None, "commits": []} + if stamp.get("source") == "commit-build": + return {**result, "reason": "commit-build", "message": COMMIT_BUILD_UPDATE_MESSAGE} + if stamp.get("payload") in {"bundled", "light"} or (install_root is None and detect_install_method(root) in {"docker", "apt"}): + return {**result, "reason": "not-a-git-checkout"} + if not embedded and not (root / ".git").exists(): + return {**result, "reason": "not-a-git-checkout", + "message": "This install has no git checkout to update."} + if stamp.get("updateMechanism") not in (None, "self") and not embedded: + return {**result, "reason": "update-root-steward-owned-git-tree", + "message": "This installation is managed by its install method; use its updater.", "advice": "git pull"} + config = require_readable_config_before_write(home / "config.yaml") + if passive and (config.get("updates") or {}).get("check") is False: + return {**result, "reason": "disabled"} + channel = channel or resolve_update_channel(config, root) + if channel not in {"main", "stable", "canary"}: + raise ValueError(f"Invalid update channel: {channel}") + head = embedded or _git_stdout(["rev-parse", "HEAD"], cwd=root, git=git) + current_branch = None if embedded else _git_stdout(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root, git=git) + desktop_config = _quiet(lambda: json.loads(branch_config_path.read_text(encoding="utf-8"))) if branch_config_path else None + configured_branch = desktop_config.get("branch") if isinstance(desktop_config, dict) else None + if isinstance(configured_branch, str): + configured_branch = configured_branch.strip() or None + else: + configured_branch = None + selected_branch = branch or configured_branch or (current_branch if current_branch and current_branch != "HEAD" else "main") + origin = "" if embedded else (_git_stdout(["remote", "get-url", "origin"], cwd=root, git=git) or "") + match = _GITHUB_ORIGIN.fullmatch(origin) + repository = OFFICIAL_REPOSITORY if embedded else (match[1] if match else None) + dirty = False if embedded else bool(_git_stdout(["status", "--porcelain"], cwd=root, git=git)) + result.update(supported=True, currentSha=head, currentBranch=current_branch, dirty=dirty) + if channel != "main": + result["channel"] = channel + else: + result["branch"] = selected_branch + identity = {"root": str(root), "home": str(home), "head": head, "origin": origin, "branch": selected_branch, + "channel": channel, "embedded": embedded} + cache_file = Path(cache_path) if cache_path is not None else home / "source-checks" / f"{install_id(root)}.json" + cached = _quiet(lambda: json.loads(cache_file.read_text(encoding="utf-8"))) + now = time.time() + if (not force and isinstance(cached, dict) and cached.get("identity") == identity + and isinstance(cached.get("status"), dict) and cached["status"].get("supported") is True): + status = cached.get("status", {}) + ttl = _UPDATE_CHECK_FAILURE_CACHE_SECONDS if status.get("error") else _UPDATE_CHECK_CACHE_SECONDS + ts = cached.get("ts") + if isinstance(ts, (float, int)) and 0 <= now - ts < ttl: + return {**status, "dirty": dirty, "currentBranch": current_branch} + result["fetchedAt"] = int(now * 1000) + if not _is_full_sha(head): + result.update(error="head-unavailable", message="Could not read the installed revision.") + elif channel in {"stable", "canary"}: + tag, target = resolve_source_release(channel, [git] if not embedded else None, root, + repository=repository or OFFICIAL_REPOSITORY) + if not tag or not target: + result.update(error="release-unavailable", message=f"Could not resolve the {channel} release commit.") + else: + result.update(latestTag=tag, targetSha=target, updateAvailable=head != target, + behind=0 if head == target else UPDATE_AVAILABLE_NO_COUNT) + else: + official_ssh = (repository and repository.lower() == OFFICIAL_REPOSITORY.lower() + and origin.lower().startswith(("git@", "ssh://"))) + # The public official repo does not require the user's SSH credentials. + # Forks must keep their own origin, including its authentication. + remote = (f"https://github.com/{OFFICIAL_REPOSITORY}.git" + if embedded or (official_ssh and selected_branch != "main") else "origin") + target, missing = _branch_tip(repository, selected_branch, root, git, remote) + if missing and selected_branch != "main": + result["branch"] = "main" + if branch_config_path and not branch and configured_branch == selected_branch: + # Do not overwrite a concurrent choice made while the network probe ran. + current_config = _quiet(lambda: json.loads(branch_config_path.read_text(encoding="utf-8"))) + if current_config == desktop_config: + from utils import atomic_json_write + atomic_json_write(branch_config_path, {**desktop_config, "branch": "main"}) + target, _ = _branch_tip(repository, "main", root, git, remote if embedded else "origin") + if target is None: + result.update(error="fetch-failed", message="Could not resolve the remote branch tip.") + else: + behind = UPDATE_AVAILABLE_NO_COUNT + if head == target or (not embedded and _git_ok( + ["merge-base", "--is-ancestor", target, head], cwd=root, git=git)): + behind = 0 + elif repository: + payload = _github_compare(head, target, repository) + ahead = (payload or {}).get("ahead_by") + if isinstance(ahead, int) and not isinstance(ahead, bool) and ahead >= 0: + behind = ahead + result["commits"] = _quiet(lambda: _commits(payload), []) if behind else [] + result.update(targetSha=target, behind=behind, updateAvailable=behind != 0) + try: + from utils import atomic_json_write + cache_file.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(cache_file, {"identity": identity, "ts": now, "status": result}) + except OSError as exc: + logger.debug("Could not cache source check: %s", exc) + return result + + +def main() -> None: + import argparse + import contextlib + import sys + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--install-root", type=Path, required=True) + parser.add_argument("--home", type=Path, required=True) + parser.add_argument("--git", default="git") + parser.add_argument("--branch") + parser.add_argument("--channel", choices=("main", "stable", "canary")) + parser.add_argument("--cache-path", type=Path) + parser.add_argument("--branch-config-path", type=Path) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + with contextlib.redirect_stdout(sys.stderr): + result = check_for_updates(**vars(args)) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/hermes_cli/source_releases.py b/hermes_cli/source_releases.py index bec74d16f2..a4ee449df6 100644 --- a/hermes_cli/source_releases.py +++ b/hermes_cli/source_releases.py @@ -4,7 +4,6 @@ from __future__ import annotations from html.parser import HTMLParser import json import logging -import os import re import subprocess import urllib.error @@ -26,9 +25,12 @@ _SHA = re.compile(r"[0-9a-f]{40}") def source_repository(git_cmd=None, cwd=None) -> str: """GitHub forks own their releases; other origins must mirror official tags.""" if git_cmd is not None: + from hermes_cli.source_check import source_git_env + result = subprocess.run( [*git_cmd, "config", "--get", "remote.origin.url"], cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, + stdin=subprocess.DEVNULL, env=source_git_env(), ) match = _GITHUB_ORIGIN.fullmatch(result.stdout.strip()) if result.returncode == 0 and match: @@ -149,12 +151,14 @@ def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=N if not isinstance(sha, str) or not _SHA.fullmatch(sha): raise ValueError(f"No published commit for release {tag}") if git_cmd is not None: + from hermes_cli.source_check import source_git_env + ref = f"refs/tags/{tag}" result = subprocess.run( [*git_cmd, "ls-remote", "--tags", "origin", ref, ref + "^{}"], cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, timeout=60, stdin=subprocess.DEVNULL, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}, + env=source_git_env(), ) refs = dict((parts[1], parts[0]) for line in result.stdout.splitlines() if len(parts := line.split()) == 2) @@ -166,36 +170,3 @@ def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=N except (OSError, ValueError, subprocess.SubprocessError) as exc: logger.warning("Could not resolve the %s source release: %s", channel, exc) return None, None - - -def main() -> None: - """Read-only JSON probe for the desktop, using the CLI's channel authority.""" - import argparse - import contextlib - import sys - from pathlib import Path - - from hermes_cli.config import load_config, require_parseable_user_config - from hermes_cli.update_channel import resolve_update_channel - - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--install-root", type=Path, required=True) - parser.add_argument("--git", default="git") - args = parser.parse_args() - # Config diagnostics must not corrupt the JSON transport. - with contextlib.redirect_stdout(sys.stderr): - # Recovery defaults are safe for repair UI, not for choosing an update target. - require_parseable_user_config() - channel = resolve_update_channel(load_config(), args.install_root) - result = {"channel": channel} - if channel in ("stable", "canary"): - tag, sha = resolve_source_release(channel, [args.git], args.install_root) - if tag is None or sha is None: - result.update(error="release-unavailable", message=f"Could not resolve the {channel} release commit.") - else: - result.update(latestTag=tag, targetSha=sha) - print(json.dumps(result)) - - -if __name__ == "__main__": - main() diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index e0383b2c26..2513fe5951 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -439,35 +439,6 @@ def _print_called_process_error_tail( # scripts/write_install_stamp.py. -def _invalidate_update_cache(): - """Delete the update-check cache for ALL profiles so no banner - reports a stale "commits behind" count after a successful update. - - The git repo is shared across profiles — when one profile runs - ``hermes update``, every profile is now current. - """ - homes = [] - # Default profile home (Docker-aware — uses /opt/data in Docker) - from hermes_constants import get_default_hermes_root - - default_home = get_default_hermes_root() - homes.append(default_home) - # Named profiles under /profiles/ - profiles_root = default_home / "profiles" - if profiles_root.is_dir(): - for entry in profiles_root.iterdir(): - if entry.is_dir(): - homes.append(entry) - for home in homes: - try: - cache_file = home / ".update_check" - if cache_file.exists(): - cache_file.unlink() - except Exception: - pass - - - def _write_update_incomplete_marker() -> None: # Historical updater hook. PM's successful facts determine completion. stop_for_relaunch() @@ -752,7 +723,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False, ch if head_sha and target_sha and head_sha == target_sha: print("✓ Already up to date.") else: - from hermes_cli.banner import _github_compare_behind + from hermes_cli.source_check import _github_compare_behind from hermes_cli.config import recommended_update_command counted = _github_compare_behind(head_sha, target_sha) @@ -1071,7 +1042,7 @@ def _prepare_checkout_for_update( apply_is_shallow = _is_shallow_checkout(git_cmd) if commit_count > 0 and apply_is_shallow: - from hermes_cli.banner import _github_compare_behind + from hermes_cli.source_check import _github_compare_behind counted = _github_compare_behind(*_tip_shas(git_cmd, target_ref)) # counted == 0 means local-ahead: falls through to the up-to-date path. commit_count = counted if counted is not None else -1 @@ -1303,7 +1274,6 @@ def _finish_already_up_to_date( _windows_gateway_resume) -> None: """"Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet. ``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt).""" - _invalidate_update_cache() # Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean + # fully merged stays on the target — re-parking on the stale branch recreates the incident. @@ -1347,7 +1317,6 @@ def _apply_pulled_update( had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan, _windows_gateway_resume) -> None: """Post-pull phase: verify HEAD, sync Python/Node/web/Desktop, maintenance, fleet restart.""" - _invalidate_update_cache() post_pull_sha = _verify_head_after_pull( git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update, _windows_gateway_resume=_windows_gateway_resume) diff --git a/hermes_cli/web_routers/actions.py b/hermes_cli/web_routers/actions.py index 473b867273..4fa173493b 100644 --- a/hermes_cli/web_routers/actions.py +++ b/hermes_cli/web_routers/actions.py @@ -297,15 +297,13 @@ async def check_hermes_update(force: bool = False): payload["message"] = non_applyable() return payload - # banner.check_for_updates() handles git / nix-revision paths through the GitHub API and + # source_check.check_for_updates() handles git / nix-revision paths through the GitHub API and # caches the result for 24h. ``force`` busts the cache so "Check now" reflects reality. try: - from hermes_cli.banner import check_for_updates, upstream_commits_behind + from hermes_cli.source_check import check_for_updates - if force: - with contextlib.suppress(OSError): - (get_hermes_home() / ".update_check").unlink() - behind = await asyncio.to_thread(check_for_updates) + status = await asyncio.to_thread(check_for_updates, force=force) + behind = status.get("behind") except Exception: _log.exception("Update check failed") behind = None @@ -319,7 +317,8 @@ async def check_hermes_update(force: bool = False): payload["update_available"] = True # "What's changed" for the desktop's remote update overlay; best-effort # (empty list on any failure). - payload["commits"] = await asyncio.to_thread(upstream_commits_behind) + payload["commits"] = [{**row, "sha": row["sha"][:7], "at": row["at"] // 1000} + for row in status.get("commits", [])[:20]] return payload diff --git a/tests/hermes_cli/test_banner_git_state.py b/tests/hermes_cli/test_banner_git_state.py index 816842ccd1..f4eec97ca9 100644 --- a/tests/hermes_cli/test_banner_git_state.py +++ b/tests/hermes_cli/test_banner_git_state.py @@ -35,99 +35,12 @@ def test_get_git_banner_state_reads_origin_and_head(tmp_path): raise AssertionError(f"unexpected command: {cmd}") return results[key] - with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + with patch("hermes_cli.source_check.subprocess.run", side_effect=fake_run): state = banner.get_git_banner_state(repo_dir) assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3} -def test_check_via_local_git_ssh_fastpath_ahead_not_behind(tmp_path): - """SSH fast path must not report an ahead (carried) HEAD as behind. - - A carried local commit means tip SHAs differ, but the fresh upstream tip - is an ancestor of HEAD — that is "ahead", and reporting it as behind - nudges the user into `hermes update`, which can wipe the carried work. - """ - from unittest.mock import MagicMock - - from hermes_cli import banner - - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) - - def fake_git_stdout(args, *, cwd, timeout=5, network=False): - if args == ["remote", "get-url", "origin"]: - return "git@github.com:NousResearch/hermes-agent.git" - if args == ["rev-parse", "HEAD"]: - return "b" * 40 # carried commit, differs from upstream tip - raise AssertionError(f"unexpected git call: {args}") - - with ( - patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), - patch.object(banner, "_github_branch_tip", return_value="a" * 40), - # merge-base --is-ancestor exits 0: upstream tip IS an ancestor of HEAD - patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=0)), - ): - behind = banner._check_via_local_git(repo_dir) - - assert behind == 0 - - -def test_check_via_local_git_ssh_fastpath_genuinely_behind(tmp_path): - """SSH fast path reports the exact count (compare API) when behind.""" - from unittest.mock import MagicMock - - from hermes_cli import banner - - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) - - def fake_git_stdout(args, *, cwd, timeout=5, network=False): - if args == ["remote", "get-url", "origin"]: - return "git@github.com:NousResearch/hermes-agent.git" - if args == ["rev-parse", "HEAD"]: - return "b" * 40 - raise AssertionError(f"unexpected git call: {args}") - - with ( - patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), - patch.object(banner, "_github_branch_tip", return_value="a" * 40), - # merge-base --is-ancestor exits 1: not an ancestor -> genuinely behind - patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)), - patch.object(banner, "_github_compare_behind", return_value=3), - ): - behind = banner._check_via_local_git(repo_dir) - - assert behind == 3 - - -def test_check_via_local_git_ssh_fastpath_offline_keeps_sentinel(tmp_path): - """Behind + compare API unreachable = honest no-count sentinel, never 1.""" - from unittest.mock import MagicMock - - from hermes_cli import banner - - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) - - def fake_git_stdout(args, *, cwd, timeout=5, network=False): - if args == ["remote", "get-url", "origin"]: - return "git@github.com:NousResearch/hermes-agent.git" - if args == ["rev-parse", "HEAD"]: - return "b" * 40 - raise AssertionError(f"unexpected git call: {args}") - - with ( - patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), - patch.object(banner, "_github_branch_tip", return_value="a" * 40), - patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)), - patch.object(banner, "_github_compare_behind", return_value=None), - ): - behind = banner._check_via_local_git(repo_dir) - - assert behind == banner.UPDATE_AVAILABLE_NO_COUNT - - def test_check_via_local_git_insteadof_rewrite_routes_to_ssh_fastpath(tmp_path, monkeypatch): """#104591: the origin-URL probe must run under the fetch's config-isolated env. @@ -173,7 +86,8 @@ def test_check_via_local_git_insteadof_rewrite_routes_to_ssh_fastpath(tmp_path, monkeypatch.setenv("USERPROFILE", str(home)) # Git for Windows resolves global config here too calls = [] - real_run = banner.subprocess.run + from hermes_cli import source_check + real_run = source_check.subprocess.run def spy_run(args, **kwargs): calls.append((list(args), kwargs)) @@ -181,10 +95,10 @@ def test_check_via_local_git_insteadof_rewrite_routes_to_ssh_fastpath(tmp_path, raise AssertionError(f"a GitHub origin must be probed via the API, not git {args[1]}") return real_run(args, **kwargs) - monkeypatch.setattr(banner.subprocess, "run", spy_run) - monkeypatch.setattr(banner, "_github_branch_tip", lambda slug, branch: head_sha) + monkeypatch.setattr(source_check.subprocess, "run", spy_run) + monkeypatch.setattr(source_check, "_branch_tip", lambda *args: (head_sha, False)) - behind = banner._check_via_local_git(repo_dir) + behind = source_check.check_for_updates(install_root=repo_dir, branch="main").get("behind") # Same upstream tip as HEAD: the SSH fast path concludes "not behind". assert behind == 0 diff --git a/tests/hermes_cli/test_banner_release_channel.py b/tests/hermes_cli/test_banner_release_channel.py index 306d82df6f..5bef6af8b0 100644 --- a/tests/hermes_cli/test_banner_release_channel.py +++ b/tests/hermes_cli/test_banner_release_channel.py @@ -5,7 +5,7 @@ from unittest.mock import Mock import pytest -from hermes_cli import banner +from hermes_cli import banner, source_check from hermes_cli.update_channel import install_id @@ -19,10 +19,9 @@ def test_release_channel_never_compares_main_or_reuses_main_cache(tmp_path, monk monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root)) monkeypatch.delenv("HERMES_REVISION", raising=False) monkeypatch.setattr("hermes_cli.config.get_project_root", lambda: root) - monkeypatch.setattr(banner, "_resolve_repo_dir", lambda: root) head = "a" * 40 target = "b" * 40 - monkeypatch.setattr(banner, "_git_stdout", lambda args, **kw: head if args == ["rev-parse", "HEAD"] else "https://github.com/NousResearch/hermes-agent.git") + monkeypatch.setattr(source_check, "_git_stdout", lambda args, **kw: head if args == ["rev-parse", "HEAD"] else "https://github.com/example/fork.git") (get_hermes_home() / "config.yaml").write_text(json.dumps({ "update": {"installs": {install_id(root): {"path": str(root), "channel": channel}}} })) @@ -30,22 +29,15 @@ def test_release_channel_never_compares_main_or_reuses_main_cache(tmp_path, monk "rev": None, "ver": banner.VERSION, "head": head, "behind": 99, "ts": 10**12, })) resolve = Mock(return_value=("v1.2.3" if channel == "stable" else "v1.2.4-canary.20260911", target)) - monkeypatch.setattr("hermes_cli.source_releases.resolve_source_release", resolve) - repository = Mock(return_value="example/fork") - monkeypatch.setattr("hermes_cli.source_releases.source_repository", repository) - main = Mock(return_value="c" * 40) - monkeypatch.setattr(banner, "_github_branch_tip", main) - compare = Mock(return_value=2) - monkeypatch.setattr(banner, "_tips_behind", compare) - - assert banner.check_for_updates(passive=True) == banner.UPDATE_AVAILABLE_NO_COUNT - repository.assert_called_once_with(["git"], root) - resolve.assert_called_once_with(channel, repository="example/fork") - compare.assert_not_called() + monkeypatch.setattr(source_check, "resolve_source_release", resolve) + main = Mock(side_effect=AssertionError("release must not check a branch")) + monkeypatch.setattr(source_check, "_branch_tip", main) + status = source_check.check_for_updates(passive=True) + assert status["behind"] == source_check.UPDATE_AVAILABLE_NO_COUNT + resolve.assert_called_once_with(channel, ["git"], root, repository="example/fork") main.assert_not_called() - cache = json.loads((get_hermes_home() / ".update_check").read_text()) - assert cache["channel"] == channel - assert cache["target"] == target + assert status["channel"] == channel + assert status["targetSha"] == target # The selected release may be an ancestor (a requested canary → stable switch). # That is still a different release, not "already current" or "behind main". label = banner.format_banner_version_label() diff --git a/tests/hermes_cli/test_commit_build_updates.py b/tests/hermes_cli/test_commit_build_updates.py index 698e95e6e9..0337e34f62 100644 --- a/tests/hermes_cli/test_commit_build_updates.py +++ b/tests/hermes_cli/test_commit_build_updates.py @@ -42,21 +42,18 @@ def test_commit_build_refuses_without_gui_advice(commit_build, git_present): @pytest.mark.parametrize("passive", [False, True]) def test_commit_build_never_checks_upstream_or_reuses_source_cache(commit_build, monkeypatch, passive): - from hermes_cli import banner + from hermes_cli import banner, source_check # Even a shared home's source-checkout cache and an embedded SHA cannot turn this into an update. monkeypatch.setenv("HERMES_REVISION", "b" * 40) - check = Mock(return_value=5) - monkeypatch.setattr(banner, "_check_via_rev", check) - cache = Mock(return_value={"behind": 5}) - monkeypatch.setattr(banner, "_read_json", cache) - assert banner.check_for_updates(passive=passive) is None + check = Mock(side_effect=AssertionError("must not probe")) + monkeypatch.setattr(source_check, "_branch_tip", check) + assert source_check.check_for_updates(passive=passive)["behind"] is None check.assert_not_called() - cache.assert_not_called() def test_commit_version_banner_uses_stamp_not_shared_checkout(commit_build, monkeypatch): - from hermes_cli import banner + from hermes_cli import banner, source_check from hermes_cli.version_info import get_version_info git = Mock(side_effect=AssertionError("version must not inspect another checkout")) @@ -73,7 +70,7 @@ def test_commit_backend_update_routes_refuse_before_checks_or_spawns(commit_buil from starlette.testclient import TestClient import hermes_cli.web_server as server import hermes_cli.web_server_gateway as gateway - from hermes_cli import banner + from hermes_cli import banner, source_check from hermes_constants import get_hermes_home monkeypatch.setattr(server, "PROJECT_ROOT", commit_build) @@ -82,7 +79,7 @@ def test_commit_backend_update_routes_refuse_before_checks_or_spawns(commit_buil spawn = Mock(side_effect=AssertionError("no updater process")) monkeypatch.setattr(gateway, "_spawn_hermes_action", spawn) check = Mock(side_effect=AssertionError("no update check")) - monkeypatch.setattr(banner, "check_for_updates", check) + monkeypatch.setattr(source_check, "check_for_updates", check) cache = get_hermes_home() / ".update_check" cache.write_text("source checkout cache") client = TestClient(server.app) diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index fe23b4a6a1..57e9c05021 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -848,7 +848,7 @@ class TestUpdateCheckEndpoint: # Stub the shared checker so the contract is deterministic (no network). import hermes_cli.banner as banner - monkeypatch.setattr(banner, "check_for_updates", lambda: 5) + monkeypatch.setattr("hermes_cli.source_check.check_for_updates", lambda **kw: {"behind": 5, "commits": []}) r = self.client.get("/api/hermes/update/check") assert r.status_code == 200 diff --git a/tests/hermes_cli/test_passive_update_opt_out.py b/tests/hermes_cli/test_passive_update_opt_out.py index 053bb1f4df..9444956589 100644 --- a/tests/hermes_cli/test_passive_update_opt_out.py +++ b/tests/hermes_cli/test_passive_update_opt_out.py @@ -1,30 +1,9 @@ """Passive opt-out keeps explicit update checks available.""" -import json import subprocess -import time from hermes_constants import get_hermes_home -def test_passive_check_obeys_config_before_using_cached_notice(monkeypatch): - from hermes_cli import banner - - home = get_hermes_home() - # The cache is keyed on the checkout's HEAD (an update moving HEAD invalidates it). - repo_dir = banner._resolve_repo_dir() - head = banner._git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) if repo_dir else None - (home / ".update_check").write_text(json.dumps({ - "ts": time.time(), "behind": 17, "rev": None, "ver": banner.VERSION, "head": head, - }), encoding="utf-8") - monkeypatch.delenv("HERMES_REVISION", raising=False) - config = home / "config.yaml" - config.write_text("updates:\n check: true\n", encoding="utf-8") - assert banner.check_for_updates(passive=True) == 17 - config.write_text("updates:\n check: false\n", encoding="utf-8") - assert banner.check_for_updates(passive=True) is None - assert banner.check_for_updates() == 17 - - def test_explicit_check_fetches_local_origin_despite_passive_opt_out(tmp_path, monkeypatch, capsys): from hermes_cli import main from hermes_cli.update_cmd import _cmd_update_check diff --git a/tests/hermes_cli/test_shallow_graft_prune.py b/tests/hermes_cli/test_shallow_graft_prune.py index a1e5aeb6ae..df96e312c8 100644 --- a/tests/hermes_cli/test_shallow_graft_prune.py +++ b/tests/hermes_cli/test_shallow_graft_prune.py @@ -113,7 +113,7 @@ def test_update_check_prunes_and_reports_count(tmp_path, monkeypatch, capsys): monkeypatch.setattr(update_cmd, "_git_run", fake_git_run) monkeypatch.setattr(update_cmd, "_base_git_cmd", lambda: ["git"]) - monkeypatch.setattr("hermes_cli.banner._github_compare_behind", lambda *a, **k: 0) + monkeypatch.setattr("hermes_cli.source_check._github_compare_behind", lambda *a, **k: 0) prune_calls = [] monkeypatch.setattr( "hermes_cli.gitlock.prune_stale_shallow_grafts", diff --git a/tests/hermes_cli/test_source_check.py b/tests/hermes_cli/test_source_check.py new file mode 100644 index 0000000000..85dfe55cbc --- /dev/null +++ b/tests/hermes_cli/test_source_check.py @@ -0,0 +1,279 @@ +"""Exercise the passive checker with real linked worktrees and loopback HTTP.""" +import json +import subprocess +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +import pytest + + +@pytest.fixture +def installation(tmp_path, monkeypatch): + root = tmp_path / "checkout" + root.mkdir() + home = tmp_path / "profile" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_REVISION", raising=False) + monkeypatch.delenv("HERMES_MANAGED", raising=False) + + def git(*args, cwd=root): + return subprocess.check_output([ + "git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", *args, + ], cwd=cwd, text=True).strip() + + git("init", "-b", "main") + git("commit", "--allow-empty", "-m", "base") + base = git("rev-parse", "HEAD") + git("commit", "--allow-empty", "-m", "local") + head = git("rev-parse", "HEAD") + git("remote", "add", "origin", "https://github.com/fixture/fork.git") + linked = tmp_path / "linked" + git("worktree", "add", "-b", "feature/gui", str(linked)) + responses = {} + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + code, body = responses.get(self.path, (404, {})) + self.send_response(code) + self.end_headers() + self.wfile.write((body if isinstance(body, str) else json.dumps(body)).encode()) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + original = urllib.request.urlopen + + def local(request, *args, **kwargs): + url = urlsplit(request.full_url) + assert url.hostname in {"api.github.com", "hermes-assets.nousresearch.com"} + return original(f"http://127.0.0.1:{server.server_port}{url.path}" + (f"?{url.query}" if url.query else ""), *args, **kwargs) + + monkeypatch.setattr(urllib.request, "urlopen", local) + yield root, linked, home, base, head, responses, requests, git + server.shutdown() + server.server_close() + thread.join() + + +def test_target_worktree_owns_admission_and_fork_comparison(installation, monkeypatch): + from hermes_cli.source_check import check_for_updates + + root, linked, home, base, head, responses, requests, git = installation + (root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "external"})) + (linked / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self"})) + cache = home / "shared-cache.json" + target = "a" * 40 + responses["/repos/fixture/fork/commits/feature%2Fgui"] = (200, target) + responses[f"/repos/fixture/fork/compare/{head}...{target}"] = (200, {"ahead_by": 3, "commits": []}) + commands = [] + original = subprocess.run + + def record(args, **kwargs): + commands.append(args) + return original(args, **kwargs) + + before = {str(p.relative_to(root)): p.read_bytes() for p in (root / ".git").rglob("*") if p.is_file()} + monkeypatch.setattr(subprocess, "run", record) + status = check_for_updates(install_root=linked, home=home, cache_path=cache) + assert status["supported"] is True + assert status["branch"] == "feature/gui" + assert status["behind"] == 3 + assert status["hermesRoot"] == str(linked) + assert check_for_updates(install_root=root, home=home, cache_path=cache)["supported"] is False + assert len(requests) == 2 + assert all(not any(arg in {"fetch", "checkout", "reset", "update-ref", "stash"} for arg in cmd) for cmd in commands) + assert git("rev-parse", "HEAD", cwd=linked) == head + assert {str(p.relative_to(root)): p.read_bytes() for p in (root / ".git").rglob("*") if p.is_file()} == before + + +@pytest.mark.parametrize("tip_kind,compare,expected", [ + ("head", None, 0), ("base", None, 0), + ("unknown", {"ahead_by": 61}, 61), ("unknown", {"ahead_by": 0}, 0), + ("unknown", None, -1), ("unknown", {"ahead_by": True}, -1), +]) +def test_counts_are_honest_without_fetch(installation, tip_kind, compare, expected): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + target = {"head": head, "base": base, "unknown": "a" * 40}[tip_kind] + responses["/repos/fixture/fork/commits/main"] = (200, target) + if compare is not None: + responses[f"/repos/fixture/fork/compare/{head}...{target}"] = (200, compare) + status = check_for_updates(install_root=root, home=home) + assert status["behind"] == expected + assert status["updateAvailable"] is (expected != 0) + if tip_kind != "unknown": + assert len(requests) == 1 + + +def test_cache_force_expiry_and_passive_opt_out(installation, monkeypatch): + from hermes_cli import source_check + root, linked, home, base, head, responses, requests, git = installation + clock = [1000000.0] + monkeypatch.setattr(source_check.time, "time", lambda: clock[0]) + url = "/repos/fixture/fork/commits/main" + responses[url] = (200, head) + check = lambda **kw: source_check.check_for_updates(install_root=root, home=home, **kw) + assert check()["behind"] == 0 + responses[url] = (503, {}) + (root / "dirty.txt").write_text("carried work") + assert check()["dirty"] is True + assert len(requests) == 1 + assert check(force=True)["error"] == "fetch-failed" + clock[0] += 3599 + assert check()["error"] == "fetch-failed" + assert len(requests) == 2 + clock[0] += 2 + responses[url] = (200, head) + assert check()["behind"] == 0 + assert len(requests) == 3 + clock[0] += 86401 + assert check()["behind"] == 0 + assert len(requests) == 4 + (home / "config.yaml").write_text("updates: {check: false}") + assert check(passive=True)["behind"] is None + assert check()["behind"] == 0 + assert len(requests) == 4 + git("commit", "--allow-empty", "-m", "moved") + responses[url] = (200, git("rev-parse", "HEAD")) + assert check()["behind"] == 0 + assert len(requests) == 5 + + +def test_explicit_and_current_branch_heal_only_after_confirmed_absence(installation, monkeypatch): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + git("remote", "set-url", "origin", str(root)) + # Use the same real linked worktree with a local origin. No GitHub fallback is involved. + assert check_for_updates(install_root=linked, home=home)["branch"] == "feature/gui" + assert check_for_updates(install_root=linked, home=home, branch="main")["branch"] == "main" + assert check_for_updates(install_root=linked, home=home, branch="deleted")["branch"] == "main" + git("remote", "set-url", "origin", str(home / "unreachable")) + failed = check_for_updates(install_root=linked, home=home, branch="deleted", force=True) + assert failed["branch"] == "deleted" + assert failed["error"] == "fetch-failed" + assert git("branch", "--show-current", cwd=linked) == "feature/gui" + assert requests == [] + + +def test_running_revision_is_not_applied_to_an_explicit_target(installation, monkeypatch): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + monkeypatch.setenv("HERMES_REVISION", "e" * 40) + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(root)) + responses["/repos/fixture/fork/commits/feature%2Fgui"] = (200, head) + assert check_for_updates(install_root=linked, home=home)["currentSha"] == head + # Default invocation retains the Nix revision probe even without a Git checkout. + monkeypatch.setattr("hermes_cli.config.get_project_root", lambda: home) + responses["/repos/NousResearch/hermes-agent/commits/main"] = (200, "e" * 40) + assert check_for_updates(home=home)["behind"] == 0 + + +def test_deleted_desktop_branch_is_persisted_only_after_definitive_probe(installation): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + branch_file = home / "desktop-update.json" + branch_file.write_text(json.dumps({"branch": "deleted", "other": "preserved"})) + git("remote", "set-url", "origin", str(home / "unreachable")) + status = check_for_updates(install_root=linked, home=home, branch_config_path=branch_file) + assert status["branch"] == "deleted" + assert json.loads(branch_file.read_text())["branch"] == "deleted" + git("remote", "set-url", "origin", str(root)) + status = check_for_updates(install_root=linked, home=home, branch_config_path=branch_file) + assert status["branch"] == "main" + assert json.loads(branch_file.read_text()) == {"branch": "main", "other": "preserved"} + + +def test_inherited_git_target_cannot_redirect_an_explicit_install(installation, monkeypatch): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + responses["/repos/fixture/fork/commits/feature%2Fgui"] = (200, head) + monkeypatch.setenv("GIT_DIR", str(root / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(root)) + status = check_for_updates(install_root=linked, home=home) + assert status["currentBranch"] == "feature/gui" + assert status["behind"] == 0 + + +@pytest.mark.parametrize("mechanism", ["external", "electron-updater", "app-installer", "microsoft-store", "self", None]) +def test_source_admission_is_stamp_owned_not_path_owned(installation, mechanism): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + if mechanism: + (linked / "install-stamp.json").write_text(json.dumps({"updateMechanism": mechanism, "distribution": "nix" if mechanism == "external" else "source"})) + responses["/repos/fixture/fork/commits/feature%2Fgui"] = (200, head) + status = check_for_updates(install_root=linked, home=home) + assert status["supported"] is (mechanism in ("self", None)) + assert len(requests) == (1 if status["supported"] else 0) + empty = home / "no-source" + empty.mkdir() + (empty / "install-stamp.json").write_text(json.dumps({"updateMechanism": mechanism, "distribution": "nix" if mechanism == "external" else "source"})) + assert check_for_updates(install_root=empty, home=home)["reason"] == "not-a-git-checkout" + + +def test_embedded_revision_keeps_https_ref_advertisement_recovery(installation, monkeypatch): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + monkeypatch.setenv("HERMES_REVISION", head) + monkeypatch.setattr("hermes_cli.config.get_project_root", lambda: home) + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root: "nix") + original = subprocess.run + probes = [] + + def advertise(args, **kwargs): + if "ls-remote" in args: + probes.append((args, kwargs)) + return subprocess.CompletedProcess(args, 0, head + "\trefs/heads/main\n", "") + return original(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", advertise) + assert check_for_updates(home=home)["behind"] == 0 + assert len(probes) == 1 + assert "https://github.com/NousResearch/hermes-agent.git" in probes[0][0] + assert probes[0][1]["stdin"] == subprocess.DEVNULL + assert probes[0][1]["env"]["GIT_TERMINAL_PROMPT"] == "0" + + +def test_malformed_optional_changelog_and_cache_do_not_hide_the_update(installation): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + cache = home / "cache.json" + responses["/repos/fixture/fork/commits/main"] = (200, "a" * 40) + responses[f"/repos/fixture/fork/compare/{head}...{'a' * 40}"] = (200, { + "ahead_by": 2, "commits": [{"sha": "b" * 40, "commit": 42}], + }) + status = check_for_updates(install_root=root, home=home, cache_path=cache) + assert status["behind"] == 2 + assert status["updateAvailable"] is True + data = json.loads(cache.read_text()) + data["status"] = None + cache.write_text(json.dumps(data)) + assert check_for_updates(install_root=root, home=home, cache_path=cache)["behind"] == 2 + assert len(requests) == 4 + + +@pytest.mark.parametrize("repository,heals", [("NousResearch/hermes-agent", True), ("fixture/fork", False)]) +def test_official_ssh_healing_uses_public_https_without_retargeting_forks(installation, monkeypatch, repository, heals): + from hermes_cli.source_check import check_for_updates + root, linked, home, base, head, responses, requests, git = installation + git("remote", "set-url", "origin", f"git@github.com:{repository}.git") + git("config", f"url.{root.as_uri()}.insteadOf", "https://github.com/NousResearch/hermes-agent.git") + monkeypatch.setenv("GIT_SSH_COMMAND", "false") + branch_file = home / "desktop-update.json" + branch_file.write_text(json.dumps({"branch": "deleted"})) + responses[f"/repos/{repository}/commits/main"] = (200, head) + status = check_for_updates(install_root=linked, home=home, branch_config_path=branch_file) + assert status["branch"] == ("main" if heals else "deleted") + assert json.loads(branch_file.read_text())["branch"] == status["branch"] + if heals: + assert status["behind"] == 0 + else: + assert status["error"] == "fetch-failed" diff --git a/tests/hermes_cli/test_source_release_probe.py b/tests/hermes_cli/test_source_release_probe.py index 1917f2095a..79beae5a53 100644 --- a/tests/hermes_cli/test_source_release_probe.py +++ b/tests/hermes_cli/test_source_release_probe.py @@ -15,11 +15,11 @@ def test_invalid_config_refuses_desktop_channel_probe(tmp_path, content): config.write_bytes(content.encode() if isinstance(content, str) else content) root = Path(__file__).resolve().parents[2] result = subprocess.run( - [sys.executable, "-m", "hermes_cli.source_releases", "--install-root", str(root)], + [sys.executable, "-m", "hermes_cli.source_check", "--install-root", str(root), "--home", str(home)], cwd=root, env={**os.environ, "HERMES_HOME": str(home), "HERMES_IGNORE_USER_CONFIG": "0"}, capture_output=True, text=True, timeout=30, ) assert result.returncode != 0, result.stdout + result.stderr assert '"channel": "main"' not in result.stdout - assert "invalid" in result.stderr + assert "config" in result.stderr assert config.read_bytes() == (content.encode() if isinstance(content, str) else content) diff --git a/tests/hermes_cli/test_update_apply_shallow_count.py b/tests/hermes_cli/test_update_apply_shallow_count.py index 272b42ad87..430418351d 100644 --- a/tests/hermes_cli/test_update_apply_shallow_count.py +++ b/tests/hermes_cli/test_update_apply_shallow_count.py @@ -47,7 +47,7 @@ def _run_count_block(*, shallow: bool, raw_count: str, api_count): with patch.object(update_cmd, "subprocess") as sub: sub.run = MagicMock(side_effect=fake) sub.CalledProcessError = real_subprocess.CalledProcessError - with patch("hermes_cli.banner._github_compare_behind", return_value=api_count): + with patch("hermes_cli.source_check._github_compare_behind", return_value=api_count): # Reproduce the block's logic against the real module state. git_cmd = ["git"] result = sub.run( @@ -63,7 +63,7 @@ def _run_count_block(*, shallow: bool, raw_count: str, api_count): == "true" ) if commit_count > 0 and apply_is_shallow: - from hermes_cli.banner import _github_compare_behind + from hermes_cli.source_check import _github_compare_behind head_sha = sub.run(git_cmd + ["rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() target_sha = sub.run( @@ -109,7 +109,7 @@ def test_shallow_local_ahead_treated_as_up_to_date(): def test_shallow_zero_count_short_circuits_without_api(): - with patch("hermes_cli.banner._github_compare_behind") as api: + with patch("hermes_cli.source_check._github_compare_behind") as api: got = _run_count_block(shallow=True, raw_count="0", api_count=None) # The block only consults the API when count > 0; a 0 count is trustworthy # (HEAD == origin tip counts 0 even on shallow graphs). diff --git a/tests/hermes_cli/test_update_behind_count_recovery.py b/tests/hermes_cli/test_update_behind_count_recovery.py index 930dd9ef3e..dbfbc440ba 100644 --- a/tests/hermes_cli/test_update_behind_count_recovery.py +++ b/tests/hermes_cli/test_update_behind_count_recovery.py @@ -1,4 +1,4 @@ -"""Behind-count recovery via the GitHub compare API (banner.py). +"""Behind-count recovery via the GitHub compare API (source_check.py). The class of bug: any code path that knows two tip SHAs but has no local history to count across (shallow installer clones, ls-remote-only probes) @@ -12,28 +12,22 @@ indicator said 1). The fix has two halves: the full graph regardless of local clone depth. """ -import io import json -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -import hermes_cli.banner as banner +import hermes_cli.source_check as source_check SHA_A = "a" * 40 SHA_B = "b" * 40 -def _compare_payload(ahead): - return io.BytesIO(json.dumps({"ahead_by": ahead, "status": "ahead"}).encode()) - - class _FakeResponse: def __init__(self, payload: bytes): self._payload = payload - def read(self): + def read(self, limit=None): return self._payload def __enter__(self): @@ -44,20 +38,12 @@ class _FakeResponse: def _patch_urlopen(payload): - banner._compare_payload_cache.clear() return patch( "urllib.request.urlopen", return_value=_FakeResponse(json.dumps(payload).encode()), ) -@pytest.fixture(autouse=True) -def _fresh_compare_cache(): - banner._compare_payload_cache.clear() - yield - banner._compare_payload_cache.clear() - - # --------------------------------------------------------------------------- # _github_compare_behind # --------------------------------------------------------------------------- @@ -65,25 +51,25 @@ def _fresh_compare_cache(): def test_compare_behind_returns_ahead_by(): with _patch_urlopen({"ahead_by": 61, "status": "ahead"}): - assert banner._github_compare_behind(SHA_A, SHA_B) == 61 + assert source_check._github_compare_behind(SHA_A, SHA_B) == 61 def test_compare_behind_zero_means_local_ahead(): with _patch_urlopen({"ahead_by": 0, "status": "behind"}): - assert banner._github_compare_behind(SHA_A, SHA_B) == 0 + assert source_check._github_compare_behind(SHA_A, SHA_B) == 0 def test_compare_behind_rejects_short_shas_without_network(): with patch("urllib.request.urlopen") as mock_open: - assert banner._github_compare_behind("abc123", SHA_B) is None - assert banner._github_compare_behind(SHA_A, "") is None - assert banner._github_compare_behind(None, SHA_B) is None + assert source_check._github_compare_behind("abc123", SHA_B) is None + assert source_check._github_compare_behind(SHA_A, "") is None + assert source_check._github_compare_behind(None, SHA_B) is None mock_open.assert_not_called() def test_compare_behind_network_failure_returns_none(): with patch("urllib.request.urlopen", side_effect=OSError("offline")): - assert banner._github_compare_behind(SHA_A, SHA_B) is None + assert source_check._github_compare_behind(SHA_A, SHA_B) is None @pytest.mark.parametrize( @@ -98,91 +84,4 @@ def test_compare_behind_network_failure_returns_none(): ) def test_compare_behind_rejects_malformed_payloads(payload): with _patch_urlopen(payload): - assert banner._github_compare_behind(SHA_A, SHA_B) is None - - -# --------------------------------------------------------------------------- -# _check_via_rev: sentinel replaced by exact count when compare API answers -# --------------------------------------------------------------------------- - - -def _upstream_tip(sha): - return patch.object(banner, "_github_branch_tip", return_value=sha) - - -def test_check_via_rev_recovers_exact_count(): - with _upstream_tip(SHA_B), patch.object(banner, "_github_compare_behind", return_value=61) as compare: - assert banner._check_via_rev(SHA_A) == 61 - compare.assert_called_once_with(SHA_A, SHA_B) - - -def test_check_via_rev_falls_back_to_sentinel_offline(): - """FAIL-BEFORE (class): this path returned a fabricated 1 via callers.""" - with _upstream_tip(SHA_B), patch.object(banner, "_github_compare_behind", return_value=None): - assert banner._check_via_rev(SHA_A) == banner.UPDATE_AVAILABLE_NO_COUNT - - -def test_check_via_rev_up_to_date_short_circuits_compare(): - with _upstream_tip(SHA_A), patch.object(banner, "_github_compare_behind") as compare: - assert banner._check_via_rev(SHA_A) == 0 - compare.assert_not_called() - - -def test_check_via_rev_local_ahead_reports_up_to_date(): - """ahead_by == 0 with differing tips = local commits on top, not behind.""" - with _upstream_tip(SHA_B), patch.object(banner, "_github_compare_behind", return_value=0): - assert banner._check_via_rev(SHA_A) == 0 - - -# --------------------------------------------------------------------------- -# _check_via_local_git: tips from the API, exact count via compare, no fetch -# --------------------------------------------------------------------------- - - -def _local_git(head_sha): - def fake_run(cmd, **kwargs): - if cmd[:4] == ["git", "remote", "get-url", "origin"]: - return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n") - if cmd[:3] == ["git", "rev-parse", "HEAD"]: - return MagicMock(returncode=0, stdout=f"{head_sha}\n") - if cmd[:3] == ["git", "merge-base", "--is-ancestor"]: - return MagicMock(returncode=1, stdout="") - raise AssertionError(f"unexpected git command: {cmd!r}") - - return fake_run - - -def test_local_checkout_recovers_exact_count(tmp_path): - """The #84591 shape: no local history across the tips (shallow clone), tips differ. - - FAIL-BEFORE (class): reported UPDATE_AVAILABLE_NO_COUNT (or, further back, - a fabricated 1) even though the compare API could count exactly. - """ - repo_dir = tmp_path / "hermes-agent" - repo_dir.mkdir() - - with patch("hermes_cli.banner.subprocess.run", side_effect=_local_git(SHA_A)), \ - patch.object(banner, "_github_branch_tip", return_value=SHA_B), \ - patch.object(banner, "_github_compare_behind", return_value=61): - assert banner._check_via_local_git(repo_dir) == 61 - - -def test_local_checkout_offline_compare_keeps_honest_sentinel(tmp_path): - repo_dir = tmp_path / "hermes-agent" - repo_dir.mkdir() - - with patch("hermes_cli.banner.subprocess.run", side_effect=_local_git(SHA_A)), \ - patch.object(banner, "_github_branch_tip", return_value=SHA_B), \ - patch.object(banner, "_github_compare_behind", return_value=None): - assert banner._check_via_local_git(repo_dir) == banner.UPDATE_AVAILABLE_NO_COUNT - - -def test_local_checkout_equal_tips_up_to_date_without_compare(tmp_path): - repo_dir = tmp_path / "hermes-agent" - repo_dir.mkdir() - - with patch("hermes_cli.banner.subprocess.run", side_effect=_local_git(SHA_A)), \ - patch.object(banner, "_github_branch_tip", return_value=SHA_A), \ - patch.object(banner, "_github_compare_behind") as compare: - assert banner._check_via_local_git(repo_dir) == 0 - compare.assert_not_called() + assert source_check._github_compare_behind(SHA_A, SHA_B) is None diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index e872988dd7..ab6484afa0 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -1,101 +1,8 @@ -"""Tests for the update check mechanism in hermes_cli.banner. - -Passive checks go through the GitHub REST API — never ``git fetch``. Every CLI, TUI and desktop -start used to fetch; across the install base that was tens of millions of fetch requests a day -and GitHub asked us to poll the API instead. These tests pin that contract plus the cache -policy that keeps the API traffic to one request a day per install. -""" - -import json import threading import time -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest - -import hermes_cli.banner as banner - -SHA_A = "a" * 40 -SHA_B = "b" * 40 - - -@pytest.fixture -def git_repo(tmp_path, monkeypatch): - """A fake checkout the update check resolves to, with git calls stubbed out.""" - repo_dir = tmp_path / "hermes-agent" - repo_dir.mkdir() - (repo_dir / ".git").mkdir() - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.delenv("HERMES_REVISION", raising=False) - monkeypatch.setattr(banner, "_resolve_repo_dir", lambda: repo_dir) - monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root: "git") - monkeypatch.setattr("hermes_cli.config.get_project_root", lambda: repo_dir) - return repo_dir - - -def _stub_git(monkeypatch, *, head=SHA_A, origin="https://github.com/NousResearch/hermes-agent.git"): - calls = [] - - def fake_run(args, **kwargs): - calls.append(list(args)) - sub = args[1] - if sub == "rev-parse": - return MagicMock(returncode=0, stdout=f"{head}\n") - if sub == "remote": - return MagicMock(returncode=0, stdout=f"{origin}\n") - if sub == "merge-base": - return MagicMock(returncode=1, stdout="") - raise AssertionError(f"passive check must not run git {sub}: {args}") - - monkeypatch.setattr(banner.subprocess, "run", fake_run) - return calls - - -def test_passive_check_uses_the_api_and_never_fetches(git_repo, monkeypatch): - """The whole point: no ``git fetch`` / ``ls-remote`` for a GitHub origin, exact count via compare.""" - calls = _stub_git(monkeypatch, head=SHA_A) - tip = MagicMock(return_value=SHA_B) - monkeypatch.setattr(banner, "_github_branch_tip", tip) - monkeypatch.setattr(banner, "_github_compare_behind", lambda cur, tgt: 61) - - assert banner.check_for_updates() == 61 - tip.assert_called_once_with("nousresearch/hermes-agent", "main") - assert not any(c[1] in {"fetch", "ls-remote"} for c in calls) - - cached = json.loads((git_repo.parent / ".update_check").read_text()) - assert (cached["head"], cached["target"], cached["behind"]) == (SHA_A, SHA_B, 61) - - -def test_cache_is_daily_but_invalidated_when_head_moves(git_repo, monkeypatch): - """A fresh cache answers without any network; ``hermes update`` moving HEAD busts it at once; - an inconclusive (None) result is retried after the shorter failure window, not never.""" - from hermes_cli import __version__ - - cache_file = git_repo.parent / ".update_check" - _stub_git(monkeypatch, head=SHA_A) - tip = MagicMock(return_value=None) - monkeypatch.setattr(banner, "_github_branch_tip", tip) - - def write_cache(*, ts, head, behind): - cache_file.write_text(json.dumps( - {"ts": ts, "behind": behind, "rev": None, "ver": __version__, "head": head})) - - write_cache(ts=time.time() - banner._UPDATE_CHECK_CACHE_SECONDS + 60, head=SHA_A, behind=3) - assert banner.check_for_updates() == 3 - tip.assert_not_called() - - write_cache(ts=time.time(), head=SHA_B, behind=3) # cached for a different HEAD - assert banner.check_for_updates() is None # API unreachable → inconclusive, re-asked - tip.assert_called_once() - - tip.reset_mock() - write_cache(ts=time.time() - banner._UPDATE_CHECK_FAILURE_CACHE_SECONDS + 60, head=SHA_A, behind=None) - assert banner.check_for_updates() is None - tip.assert_not_called() - - write_cache(ts=time.time() - banner._UPDATE_CHECK_FAILURE_CACHE_SECONDS - 1, head=SHA_A, behind=None) - banner.check_for_updates() - tip.assert_called_once() +from hermes_cli import banner, source_check def test_prefetch_non_blocking(): @@ -103,23 +10,9 @@ def test_prefetch_non_blocking(): banner._update_result = None banner._update_check_done = threading.Event() - with patch.object(banner, "check_for_updates", return_value=5): + with patch.object(source_check, "check_for_updates", return_value={"behind": 5}): start = time.monotonic() banner.prefetch_update_check() assert time.monotonic() - start < 1.0 banner._update_check_done.wait(timeout=5) assert banner._update_result == 5 - - -def test_upstream_main_sha_ls_remote_fallback_disables_git_prompts(monkeypatch): - """When the API is unreachable the HTTPS ls-remote fallback must never inherit the terminal.""" - monkeypatch.setattr(banner, "_github_branch_tip", lambda slug, branch: None) - completed = MagicMock(returncode=1, stdout="", stderr="auth required") - run = MagicMock(return_value=completed) - monkeypatch.setattr(banner.subprocess, "run", run) - - assert banner._upstream_main_sha() is None - kwargs = run.call_args.kwargs - assert kwargs["stdin"] is banner.subprocess.DEVNULL - assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" - assert kwargs["env"]["GCM_INTERACTIVE"] == "Never" From bb57c51795a4153471d663202f88091bb4aa2400 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:02:07 -0400 Subject: [PATCH 11/33] Remove unused desktop update counter --- apps/desktop/electron/update-count.test.ts | 302 --------------------- apps/desktop/electron/update-count.ts | 115 -------- 2 files changed, 417 deletions(-) delete mode 100644 apps/desktop/electron/update-count.test.ts delete mode 100644 apps/desktop/electron/update-count.ts diff --git a/apps/desktop/electron/update-count.test.ts b/apps/desktop/electron/update-count.test.ts deleted file mode 100644 index d3281f126c..0000000000 --- a/apps/desktop/electron/update-count.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { test } from 'vitest' - -import { - compareApiUrl, - parseCompareBehindCount, - resolveBehindCount, - resolveCommitLogSelection, - shouldCountCommits -} from './update-count' - -function createTempGitRepo() { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-count-')) - const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }).trim() - - try { - git('init', '--quiet') - git('config', 'commit.gpgSign', 'false') - git('config', 'core.hooksPath', '.git/no-hooks') - git('config', 'user.name', 'Hermes Test') - git('config', 'user.email', 'hermes@example.invalid') - - return { cwd, git } - } catch (error) { - fs.rmSync(cwd, { recursive: true, force: true }) - throw error - } -} - -// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` -// unconditionally, so a shallow checkout with no merge-base surfaced the bogus -// rev-list count (e.g. 12104) — #51922. Later the branch returned the sentinel -// `1`, which the UI rendered as a literal "1 change included" even when the -// true count was far higher (e.g. 90, or the real-world 61 in #84591). An -// update IS available here, but its exact size is unknown — the only honest -// value is `null`. -test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => { - assert.equal( - resolveBehindCount({ - countStr: '12104', - currentSha: 'aaa', - targetSha: 'bbb', - isShallow: true - }), - null - ) -}) - -test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => { - assert.equal( - resolveBehindCount({ - countStr: '12104', - currentSha: 'abc', - targetSha: 'abc', - isShallow: true - }), - 0 - ) -}) - -test('shallow local-ahead checkout reports up-to-date when origin is a known ancestor', () => { - assert.equal( - resolveBehindCount({ - countStr: '', - currentSha: 'local-child', - targetSha: 'origin-parent', - isShallow: true, - targetIsAncestorOfHead: true - }), - 0 - ) -}) - -test('shallow Git graph proves the remote tip is an ancestor of a local commit', () => { - const { cwd, git } = createTempGitRepo() - - try { - git('commit', '--allow-empty', '-m', 'origin tip') - - const targetSha = git('rev-parse', 'HEAD') - - git('update-ref', 'refs/remotes/origin/main', targetSha) - fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${targetSha}\n`) - git('commit', '--allow-empty', '-m', 'local child') - - const currentSha = git('rev-parse', 'HEAD') - - git('merge-base', '--is-ancestor', 'origin/main', 'HEAD') - assert.notEqual(currentSha, targetSha) - assert.equal( - resolveBehindCount({ - countStr: '', - currentSha, - targetSha, - isShallow: true, - targetIsAncestorOfHead: true - }), - 0 - ) - } finally { - fs.rmSync(cwd, { recursive: true, force: true }) - } -}, 30_000) - -test('shallow checkout with a merge-base does not trust an inflated rev-list count', () => { - const { cwd, git } = createTempGitRepo() - - try { - git('commit', '--allow-empty', '-m', 'root') - git('commit', '--allow-empty', '-m', 'ancestor') - - const redundantParent = git('rev-parse', 'HEAD') - - git('commit', '--allow-empty', '-m', 'installed head') - - const currentSha = git('rev-parse', 'HEAD') - const tree = git('rev-parse', 'HEAD^{tree}') - - const targetSha = execFileSync('git', ['commit-tree', tree, '-p', currentSha, '-p', redundantParent], { - cwd, - encoding: 'utf8', - input: 'remote merge\n', - timeout: 10_000 - }).trim() - - git('update-ref', 'refs/remotes/origin/main', targetSha) - - const completeCount = git('rev-list', 'HEAD..origin/main', '--count') - - assert.equal(completeCount, '1') - - fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${currentSha}\n`) - - assert.equal(git('rev-parse', '--is-shallow-repository'), 'true') - assert.equal(git('merge-base', 'HEAD', 'origin/main'), currentSha) - - const shallowCount = git('rev-list', 'HEAD..origin/main', '--count') - - assert.ok(Number.parseInt(shallowCount, 10) > Number.parseInt(completeCount, 10)) - assert.equal( - resolveBehindCount({ - countStr: shallowCount, - currentSha, - targetSha, - isShallow: true - }), - null - ) - } finally { - fs.rmSync(cwd, { recursive: true, force: true }) - } -}, 30_000) - -test('shallow checkout with a merge-base still uses presence-only status', () => { - assert.equal( - resolveBehindCount({ - countStr: '3', - currentSha: 'aaa', - targetSha: 'bbb', - isShallow: true - }), - null - ) -}) - -test('full (non-shallow) clone keeps the exact count path unchanged', () => { - assert.equal( - resolveBehindCount({ - countStr: '7', - currentSha: 'aaa', - targetSha: 'bbb', - isShallow: false - }), - 7 - ) -}) - -test('up-to-date full clone reports 0', () => { - assert.equal( - resolveBehindCount({ - countStr: '0', - currentSha: 'x', - targetSha: 'x', - isShallow: false - }), - 0 - ) -}) - -test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => { - assert.equal( - resolveBehindCount({ - countStr: '', - currentSha: 'aaa', - targetSha: 'bbb', - isShallow: false - }), - 0 - ) -}) - -// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates(). -// Every shallow graph is incomplete, so a visible merge-base is not enough to -// prove that the count is exact. -test('shallow checkouts skip the rev-list count', () => { - assert.equal(shouldCountCommits({ isShallow: true }), false) -}) - -test('full (non-shallow) clones run the rev-list count', () => { - assert.equal(shouldCountCommits({ isShallow: false }), true) -}) - -test('shallow commit logs select only the fetched remote tip', () => { - assert.deepEqual(resolveCommitLogSelection({ branch: 'main', isShallow: true }), { - limit: 1, - revision: 'origin/main' - }) -}) - -test('full-clone commit logs keep the complete behind range', () => { - assert.deepEqual(resolveCommitLogSelection({ branch: 'release', isShallow: false }), { - limit: 40, - revision: 'HEAD..origin/release' - }) -}) - -// The skip path produces an empty countStr; resolveBehindCount must NOT trust -// it and must fall through to the SHA compare (mirrors the live call site). -test('skipped-count path resolves via SHA compare, never via empty countStr', () => { - assert.equal( - resolveBehindCount({ - countStr: '', - currentSha: 'aaa', - targetSha: 'bbb', - isShallow: true - }), - null - ) - assert.equal( - resolveBehindCount({ - countStr: '', - currentSha: 'same', - targetSha: 'same', - isShallow: true - }), - 0 - ) -}) - -// --- compare-API recovery: the accuracy half of the class fix (#84591) --- - -const SHA_A = 'a'.repeat(40) -const SHA_B = 'b'.repeat(40) - -test('compareApiUrl builds the GitHub compare URL for HTTPS origins', () => { - assert.equal( - compareApiUrl({ - currentSha: SHA_A, - originUrl: 'https://github.com/NousResearch/hermes-agent.git', - targetSha: SHA_B - }), - `https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}` - ) -}) - -test('compareApiUrl handles SSH origin forms', () => { - for (const originUrl of [ - 'git@github.com:NousResearch/hermes-agent.git', - 'ssh://git@github.com/NousResearch/hermes-agent.git', - 'git@github.com:NousResearch/hermes-agent' - ]) { - assert.equal( - compareApiUrl({ currentSha: SHA_A, originUrl, targetSha: SHA_B }), - `https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}` - ) - } -}) - -test('compareApiUrl refuses non-GitHub remotes and partial SHAs', () => { - assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: 'https://gitlab.com/x/y.git', targetSha: SHA_B }), null) - assert.equal(compareApiUrl({ currentSha: 'abc123', originUrl: 'https://github.com/x/y.git', targetSha: SHA_B }), null) - assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: '', targetSha: SHA_B }), null) -}) - -test('parseCompareBehindCount returns ahead_by (the behind count)', () => { - assert.equal(parseCompareBehindCount({ ahead_by: 61, status: 'ahead' }), 61) - assert.equal(parseCompareBehindCount({ ahead_by: 0, status: 'behind' }), 0) -}) - -test('parseCompareBehindCount rejects malformed payloads', () => { - assert.equal(parseCompareBehindCount(null), null) - assert.equal(parseCompareBehindCount({}), null) - assert.equal(parseCompareBehindCount({ ahead_by: -2 }), null) - assert.equal(parseCompareBehindCount({ ahead_by: '61' }), null) - assert.equal(parseCompareBehindCount({ ahead_by: 1.5 }), null) - assert.equal(parseCompareBehindCount([]), null) -}) diff --git a/apps/desktop/electron/update-count.ts b/apps/desktop/electron/update-count.ts deleted file mode 100644 index ebca95271a..0000000000 --- a/apps/desktop/electron/update-count.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Whether `git rev-list HEAD..origin/ --count` produces a meaningful -// number worth computing. Installer checkouts are shallow (`--depth 1`), so -// their visible graph is incomplete even when `merge-base` happens to find a -// common commit. A merge can expose ancestry that the local shallow boundary -// hides from HEAD, inflating the count with old commits. Exact counts are only -// trustworthy in full clones; shallow checkouts use presence-only status plus -// any positively proven local-ahead ancestry. -function shouldCountCommits({ isShallow }: { isShallow: boolean }): boolean { - return !isShallow -} - -// Resolve how many commits the local checkout is behind origin for the desktop -// update indicator. Shallow checkouts use SHA equality plus any positively -// proven local-ahead ancestry; exact counts remain exclusive to full clones. -function resolveBehindCount({ - countStr, - currentSha, - targetSha, - isShallow, - targetIsAncestorOfHead = false -}: { - countStr: string - currentSha: string - targetSha: string - isShallow: boolean - targetIsAncestorOfHead?: boolean -}): number | null { - if (!shouldCountCommits({ isShallow })) { - if (currentSha && targetSha && (currentSha === targetSha || targetIsAncestorOfHead)) { - return 0 - } - - // An update IS available, but its size is unknowable without a merge-base. - // Return null — never a numeric sentinel: the UI used to render the old - // `1` as a literal "1 change included" even when the true distance was - // far larger. null lets every surface say "update available" honestly. - return null - } - - return Number.parseInt(countStr, 10) || 0 -} - -// Shallow history can also contaminate the changelog range. Trust the fetched -// remote tip itself, but do not walk its ancestry. Full clones retain the -// detailed range used by the existing update overlay. -function resolveCommitLogSelection({ branch, isShallow }: { branch: string; isShallow: boolean }): { - limit: number - revision: string -} { - const remote = `origin/${branch}` - - return isShallow ? { limit: 1, revision: remote } : { limit: 40, revision: `HEAD..${remote}` } -} - -// When the local graph can't count (behind === null), the GitHub compare API -// still can: `GET /repos///compare/...` returns -// `ahead_by` — how many commits the remote tip is ahead of the local HEAD, -// i.e. exactly the behind count the shallow clone lost. Unauthenticated, no -// clone depth required. Pure URL builder + response parser here; the network -// call lives with the caller. -function compareApiUrl({ - currentSha, - originUrl, - targetSha -}: { - currentSha: string - originUrl: string - targetSha: string -}): string | null { - const sha = /^[0-9a-f]{40}$/i - - if (!sha.test(currentSha || '') || !sha.test(targetSha || '')) { - return null - } - - // Only GitHub remotes have a compare API. Reuse the canonical form the - // official-remote check produces: `github.com//`. - const canonical = canonicalRemoteForCompare(originUrl) - - if (!canonical) { - return null - } - - return `https://api.github.com/repos/${canonical}/compare/${currentSha}...${targetSha}` -} - -function canonicalRemoteForCompare(originUrl: string): string | null { - const value = String(originUrl || '').trim() - - const match = - /^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) || - /^(?:ssh:\/\/git@|https:\/\/|http:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) - - return match ? match[1] : null -} - -// `ahead_by` counts target commits not reachable from current — the behind -// count. `status` is "ahead" / "behind" / "diverged" / "identical" relative to -// current...target; any shape surprise returns null so the caller keeps the -// honest "update available" fallback instead of trusting a partial answer. -function parseCompareBehindCount(payload: unknown): number | null { - if (!payload || typeof payload !== 'object') { - return null - } - - const ahead = (payload as { ahead_by?: unknown }).ahead_by - - if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) { - return null - } - - return ahead -} - -export { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits } From 02f9a241d30af4636a1191d88de023a457e98a1d Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:03:39 -0400 Subject: [PATCH 12/33] Let PM own Git environments and Matrix dependency preparation Delete consumer-side Git discovery caches and layout reconstruction. Share the selected environment while preserving PM acquisition policy, system Git fallback and downstream config isolation. Route Matrix binding through its existing dependency operation and explicit setup through sync_venv. Real Git regression tests cover absent ambient PATH, changed selection, unsupported target and failed acquisition. Independent final review: 100 focused tests pass, including malicious Git config and Matrix setup contracts. Native Windows execution was not run. --- hermes_cli/_subprocess_compat.py | 17 ++++ plugins/platforms/matrix/adapter.py | 88 ++++++------------ tests/gateway/test_matrix_deps_rebind.py | 26 +++++- tests/tools/test_checkpoint_manager.py | 4 +- .../tools/test_checkpoint_store_ownership.py | 2 +- tests/tools/test_git_consumer_environment.py | 91 +++++++++++++++++++ tools/checkpoint_manager.py | 68 +++----------- tools/working_diff.py | 40 ++------ 8 files changed, 182 insertions(+), 154 deletions(-) create mode 100644 tests/tools/test_git_consumer_environment.py diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 150e6e814f..736df8b36b 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -27,6 +27,7 @@ __all__ = [ "windows_detach_popen_kwargs", "bounded_git_probe", "bounded_probe_run", + "selected_git_env", "noninteractive_git_env", "NO_DRIVER_DIFF_FLAGS", "pid_is_hermes", @@ -345,6 +346,22 @@ def _user_safe_directories(base_env: "Mapping[str, str]") -> list[str]: return values +def selected_git_env(base: Mapping[str, str] | None = None) -> dict[str, str]: + """PM's full Git environment, or the original base for system-Git fallback. + + Keep lazy acquisition under PM's policy (not just installed-package lookup). + Unsupported targets and failed acquisition must not disable a working system + Git. Callers apply their own config/security isolation after selection. + """ + env = dict(base if base is not None else os.environ) + try: + from pm import ensure + + return ensure("git", base_env=env).env + except Exception: + return env + + def noninteractive_git_env(base: "Mapping[str, str] | None" = None) -> dict[str, str]: """Environment for *internal* git invocations that must never prompt. diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 1b0a253ab7..197bb6f397 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -718,42 +718,28 @@ def ensure_matrix_deps() -> bool: forever and broke E2EE connect with ``No module named 'asyncpg'`` (#31116). Rebinds module-level type globals on success. """ - # Check every anchor of the matrix extra (mautrix alone is not enough: - # a partial install left asyncpg missing forever, #31116). - try: - from pm.extras import missing as _extra_missing, ensure_and_bind - missing = _extra_missing("matrix") - except Exception as exc: # pragma: no cover — defensive - logger.debug("Matrix: extras lookup failed: %s", exc) - missing = () - ensure_and_bind = None # type: ignore[assignment] - if ensure_and_bind is None: - return False - if missing: - def _import(): - from mautrix.types import ( - ContentURI, EventID, EventType, PresenceState, RoomCreatePreset, RoomID, TrustState, UserID) - return { - "ContentURI": ContentURI, - "EventID": EventID, - "EventType": EventType, - "PresenceState": PresenceState, - "RoomCreatePreset": RoomCreatePreset, - "RoomID": RoomID, - "TrustState": TrustState, - "UserID": UserID, - } + from pm.extras import ensure_and_bind - if ensure_and_bind is None: - return False - if not ensure_and_bind("matrix", _import, globals()): - logger.warning( - "Matrix: required packages not installed (%s). " - "Run: pip install 'mautrix[encryption]' asyncpg aiosqlite " - "Markdown aiohttp-socks", - ", ".join(missing) if missing else "matrix", - ) - return False + def _import(): + from mautrix.types import ( + ContentURI, EventID, EventType, PresenceState, RoomCreatePreset, RoomID, TrustState, UserID) + return { + "ContentURI": ContentURI, + "EventID": EventID, + "EventType": EventType, + "PresenceState": PresenceState, + "RoomCreatePreset": RoomCreatePreset, + "RoomID": RoomID, + "TrustState": TrustState, + "UserID": UserID, + } + + if not ensure_and_bind("matrix", _import, globals()): + logger.warning( + "Matrix: required packages not installed or need a restart. " + "Run `hermes pm install`, then restart Hermes." + ) + return False e2ee_mode = _resolve_e2ee_mode() if e2ee_mode == "required" and not _check_e2ee_deps(): logger.error( @@ -3018,31 +3004,15 @@ def interactive_setup() -> None: if want_e2ee: save_env_value("MATRIX_ENCRYPTION", "true") print_success("E2EE enabled") - matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix" try: - from pm import ensure_import as _lazy_ensure - from pm.extras import missing as _extra_missing - _missing_before = _extra_missing("matrix") - if _missing_before: - print_info(f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)...") - try: - _lazy_ensure("matrix") - print_success(f"{matrix_pkg} installed") - except Exception as exc: - print_warning( - "Install failed — run manually: pip install " - "'mautrix[encryption]' asyncpg aiosqlite Markdown aiohttp-socks" - ) - print_info(f" Error: {exc}") - except ImportError: - try: - _lazy_ensure("matrix") - print_success(f"{matrix_pkg} installed") - except Exception as exc: - print_warning( - "Install failed — run manually: pip install " - "'mautrix[encryption]' asyncpg aiosqlite Markdown aiohttp-socks") - print_info(f" Error: {exc}") + from pm import sync_venv + + print_info("Preparing Matrix dependencies...") + sync_venv(["matrix"], explicit=True) + print_success("Matrix dependencies prepared. Restart Hermes to use them.") + except Exception as exc: + print_warning(f"Matrix dependencies could not be prepared: {exc}") + print_info("Run `hermes pm install`, then restart Hermes.") print_info("🔒 Security: Restrict who can use your bot") print_info(" Matrix user IDs look like @username:server") allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") diff --git a/tests/gateway/test_matrix_deps_rebind.py b/tests/gateway/test_matrix_deps_rebind.py index 8791255438..5825ba0e7b 100644 --- a/tests/gateway/test_matrix_deps_rebind.py +++ b/tests/gateway/test_matrix_deps_rebind.py @@ -58,11 +58,10 @@ def _fake_mautrix_types(): return mod -@pytest.fixture -def fresh_dependency_boundary(monkeypatch): - """Simulate a fresh install: ``missing()`` reports a gap, the install is a - no-op success, and ``from mautrix.types import ...`` resolves against a fake.""" - monkeypatch.setattr(pm_extras, "missing", lambda extra: ("asyncpg",)) +@pytest.fixture(params=[(), ("asyncpg",)], ids=["installed", "fresh"]) +def fresh_dependency_boundary(monkeypatch, request): + """Installed and fresh dependencies both bind through PM's single operation.""" + monkeypatch.setattr(pm_extras, "missing", lambda extra: request.param) monkeypatch.setattr(pm_extras, "ensure_import", lambda *a, **kw: None) monkeypatch.delenv("MATRIX_E2EE_MODE", raising=False) monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) @@ -94,3 +93,20 @@ def test_failed_install_returns_false_with_hint_and_never_raises( with caplog.at_level("WARNING", logger="plugins.platforms.matrix.adapter"): assert matrix_adapter.ensure_matrix_deps() is False assert any("required packages not installed" in r.message for r in caplog.records) + + +def test_interactive_setup_explicitly_syncs_matrix(tmp_path, monkeypatch): + import pm + from hermes_cli import cli_output, config + + answers = iter(["https://matrix.example.test", "test-token", "@bot:example.test", "@owner:example.test", "!home:example.test"]) + monkeypatch.setattr(cli_output, "prompt", lambda *args, **kwargs: next(answers)) + monkeypatch.setattr(cli_output, "prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr(config, "get_env_value", lambda key: None) + monkeypatch.setattr(config, "save_env_value", lambda *args: None) + calls = [] + monkeypatch.setattr(pm, "sync_venv", lambda extras, **kwargs: calls.append((extras, kwargs))) + monkeypatch.setattr(pm, "ensure_import", lambda *args: pytest.fail("setup used implicit installation")) + monkeypatch.setattr(pm_extras, "missing", lambda extra: ("asyncpg",)) + matrix_adapter.interactive_setup() + assert calls == [(["matrix"], {"explicit": True})] diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index 915e01bae2..1f3a944129 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -688,8 +688,8 @@ class TestErrorResilience: assert mgr.ensure_checkpoint(str(work_dir), "test") is False # ...and when git isn't installed at all. - monkeypatch.setattr("shutil.which", lambda x: None) - mgr._git_available = None + monkeypatch.setattr("shutil.which", lambda *args, **kwargs: None) + mgr.new_turn() assert mgr.ensure_checkpoint(str(work_dir), "test") is False diff --git a/tests/tools/test_checkpoint_store_ownership.py b/tests/tools/test_checkpoint_store_ownership.py index 458983cb96..371407fe50 100644 --- a/tests/tools/test_checkpoint_store_ownership.py +++ b/tests/tools/test_checkpoint_store_ownership.py @@ -25,7 +25,7 @@ import sys from pathlib import Path from tools import checkpoint_manager as c c.CHECKPOINT_BASE = Path(sys.argv[1]) -c._managed_git = lambda: None + run = c._run_git def paused(args, *rest, **kwargs): result = run(args, *rest, **kwargs) diff --git a/tests/tools/test_git_consumer_environment.py b/tests/tools/test_git_consumer_environment.py new file mode 100644 index 0000000000..d48984792e --- /dev/null +++ b/tests/tools/test_git_consumer_environment.py @@ -0,0 +1,91 @@ +"""Git consumers use the selected package environment without owning its layout.""" +import importlib +import shutil +import subprocess + +import pytest + +import pm +from tools import checkpoint_manager, working_diff + + +@pytest.mark.platforms("posix") +def test_checkpoint_and_diff_follow_selected_git_environment(tmp_path, monkeypatch): + git = shutil.which("git") + assert git is not None + selected = tmp_path / "selected-tools" + selected.mkdir() + observed = tmp_path / "git-environment" + wrapper = selected / "git" + import shlex + + wrapper.write_text( + '#!/bin/sh\n' + 'test "$HERMES_GIT_HELPER" = "selected" || exit 77\n' + f'printf "%s\\n" selected >> {shlex.quote(str(observed))}\n' + f'exec {shlex.quote(git)} "$@"\n', + encoding="utf-8", + ) + wrapper.chmod(0o755) + + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run([git, "init", str(repo)], check=True, capture_output=True) + (repo / "new.txt").write_text("new content\n", encoding="utf-8") + + def selected_git(name, *, base_env): + assert name == "git" + return pm.Runner(name, { + **base_env, "PATH": str(selected), "HERMES_GIT_HELPER": "selected" + }) + + monkeypatch.setattr(pm, "ensure", selected_git) + monkeypatch.setenv("PATH", str(tmp_path / "missing-path")) + monkeypatch.setattr(checkpoint_manager, "CHECKPOINT_BASE", tmp_path / "checkpoints") + manager = checkpoint_manager.CheckpointManager(enabled=True) + assert manager.ensure_checkpoint(str(repo)) is True + result = working_diff.collect_working_diff(str(repo)) + assert result["success"] is True + assert "+new content" in result["diff"] + assert observed.read_text(encoding="utf-8").splitlines() + + # A changed PM selection must affect this same process, not a cached command. + monkeypatch.setattr(pm, "ensure", lambda name, *, base_env: pm.Runner(name, { + **base_env, "PATH": str(tmp_path / "missing-path") + })) + assert working_diff.collect_working_diff(str(repo))["success"] is False + manager.new_turn() + assert manager.ensure_checkpoint(str(repo)) is False + + +@pytest.mark.parametrize("consumer", ["checkpoint", "diff"]) +@pytest.mark.parametrize("failure", ["unsupported-target", "acquisition"]) +def test_git_consumers_fall_back_to_system_git(tmp_path, monkeypatch, consumer, failure): + git = shutil.which("git") + assert git is not None + repo = tmp_path / "repo" + subprocess.run([git, "init", str(repo)], check=True, capture_output=True) + (repo / "new.txt").write_text("system git content\n", encoding="utf-8") + + # Fail at the PM boundary reached by both ensure and env_for, without + # pretending this interpreter is running on a different host OS/CPU. + resolver = importlib.import_module("pm.ensure") + calls = [] + + def unavailable(*args, **kwargs): + calls.append(True) + if failure == "unsupported-target": + raise RuntimeError("unsupported architecture") + raise pm.InstallError("git", "managed Git unavailable") + + boundary = "current_target" if failure == "unsupported-target" else "_installed_location" + monkeypatch.setattr(resolver, boundary, unavailable) + monkeypatch.setattr(checkpoint_manager, "CHECKPOINT_BASE", tmp_path / "checkpoints") + if consumer == "checkpoint": + manager = checkpoint_manager.CheckpointManager(enabled=True) + assert manager.ensure_checkpoint(str(repo)) is True + else: + result = working_diff.collect_working_diff(str(repo)) + assert result["success"] is True + assert "+system git content" in result["diff"] + assert calls diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 7196a3b457..09d0ed3632 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -48,7 +48,6 @@ reclaim object storage. A size-cap pass drops the oldest checkpoints per project until total store size is under ``max_total_size_mb``. """ -import functools import hashlib import json import logging @@ -59,7 +58,7 @@ import subprocess import time from pathlib import Path from hermes_constants import get_hermes_home -from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_cli._subprocess_compat import selected_git_env, windows_hide_flags from typing import Dict, List, Optional, Set, Tuple from utils import env_int @@ -289,33 +288,6 @@ def _project_meta_path(store: Path, dir_hash: str) -> Path: # Git env # --------------------------------------------------------------------------- -@functools.lru_cache(maxsize=1) -def _managed_git() -> Optional[Tuple[List[str], List[str]]]: - """(git invocation, extra PATH dirs) from pm's pinned Git for Windows. - - pm's git package is the canonical Windows git (Git for Windows, - pinned in pm/lock.json); its PATH dirs make the MSYS helpers - (sh.exe, git-remote-*) resolvable to the child. Returns None when pm - cannot provide git — the deliberate POSIX gap (system git by choice), - not installed, or lazy installs disabled — and the caller falls back - to bare ``git`` on PATH. Cached: checkpoints fire several git calls - per turn, so the store lookup should not repeat. - """ - try: - import pm - - runner = pm.ensure("git") - for candidate in ("git.exe", "git"): - resolved = shutil.which(candidate, path=runner.env.get("PATH")) - if resolved: - git_dir = str(Path(resolved).resolve().parent) - usr_bin = str(Path(resolved).resolve().parent.parent / "usr" / "bin") - return [resolved], [git_dir, usr_bin] - except Exception: - pass - return None - - def _git_env( store: Path, working_dir: str, @@ -341,7 +313,8 @@ def _git_env( # git child with hand-isolated config env; exact preservation — a HOME # rewrite would change which ~/.gitconfig the isolation vars are hiding. from tools.environments.local import build_subprocess_env - env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) + + env = selected_git_env(build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)) env["GIT_DIR"] = str(store) env["GIT_WORK_TREE"] = str(normalized_working_dir) env.pop("GIT_NAMESPACE", None) @@ -384,15 +357,10 @@ def _run_git( env = _git_env(store, str(normalized_working_dir), index_file=index_file) if extra_env: env.update(extra_env) - managed = _managed_git() - if managed: - cmd, path_dirs = managed - # The store's MSYS dirs must be reachable so git.exe can resolve - # its helpers; prepend them to the isolated child env. - env["PATH"] = os.pathsep.join(path_dirs) + os.pathsep + env.get("PATH", "") - else: - cmd = ["git"] - cmd = cmd + list(args) + git = shutil.which("git", path=env.get("PATH", "")) + if git is None: + return False, "", "git is not installed or not on PATH" + cmd = [git, *args] allowed_returncodes = allowed_returncodes or set() try: @@ -517,7 +485,8 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]: # here (which always sets GIT_DIR + GIT_WORK_TREE). Use a raw # subprocess with just the config-isolation env vars. from tools.environments.local import build_subprocess_env - init_env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) + + init_env = selected_git_env(build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)) init_env["GIT_CONFIG_GLOBAL"] = os.devnull init_env["GIT_CONFIG_SYSTEM"] = os.devnull init_env["GIT_CONFIG_NOSYSTEM"] = "1" @@ -526,14 +495,11 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]: "GIT_ALTERNATE_OBJECT_DIRECTORIES"): init_env.pop(k, None) try: - managed = _managed_git() - git_cmd, path_dirs = managed if managed else (["git"], None) - if path_dirs: - init_env["PATH"] = ( - os.pathsep.join(path_dirs) + os.pathsep + init_env.get("PATH", "") - ) + git = shutil.which("git", path=init_env.get("PATH", "")) + if git is None: + return "Shadow store init failed: git is not installed or not on PATH" result = subprocess.run( - git_cmd + ["init", "--bare", str(store)], + [git, "init", "--bare", str(store)], capture_output=True, text=True, encoding='utf-8', errors='replace', env=init_env, timeout=_GIT_TIMEOUT, stdin=subprocess.DEVNULL, @@ -781,7 +747,6 @@ class CheckpointManager: self.max_total_size_mb = max(0, int(max_total_size_mb)) self.max_file_size_mb = max(0, int(max_file_size_mb)) self._checkpointed_dirs: Set[str] = set() - self._git_available: Optional[bool] = None # lazy probe # ------------------------------------------------------------------ # Turn lifecycle @@ -897,13 +862,6 @@ class CheckpointManager: if not self.enabled: return False - if self._git_available is None: - self._git_available = shutil.which("git") is not None - if not self._git_available: - logger.debug("Checkpoints disabled: git not found") - if not self._git_available: - return False - abs_dir = str(_normalize_path(working_dir)) # Skip root, home, and other overly broad directories diff --git a/tools/working_diff.py b/tools/working_diff.py index 55a70d9b2c..cb8a2e8b61 100644 --- a/tools/working_diff.py +++ b/tools/working_diff.py @@ -9,14 +9,13 @@ brand-new files show as additions instead of being invisible. from __future__ import annotations -import functools import os import shutil import subprocess from contextlib import suppress -from typing import Dict, List, Optional +from typing import Dict, List -from hermes_cli._subprocess_compat import harden_git_argv, noninteractive_git_env +from hermes_cli._subprocess_compat import harden_git_argv, noninteractive_git_env, selected_git_env _GIT_TIMEOUT = 15 _MAX_UNTRACKED_FILES = 50 # sanity cap so a node_modules explosion can't hang us @@ -29,30 +28,6 @@ _MODE_ARGS = { VALID_MODES = tuple(_MODE_ARGS) -@functools.lru_cache(maxsize=1) -def _git_command() -> Optional[List[str]]: - """Resolve the git invocation: pm's pinned Git first, then system git. - - pm's git package is the canonical Windows git (Git for Windows, - pinned in pm/lock.json) — it wins over PATH so a stale or broken - system git never breaks diff collection. On POSIX pm deliberately - gaps git (system git by choice), and when pm can't provide it for any - other reason we fall back to bare ``git`` on PATH. None when git is - nowhere — the caller reports it unavailable. - """ - try: - import pm - - runner = pm.ensure("git") - for candidate in ("git.exe", "git"): - resolved = shutil.which(candidate, path=runner.env.get("PATH")) - if resolved: - return [resolved] - except Exception: - pass - return ["git"] if shutil.which("git") else None - - def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT): """Run git, returning (returncode, stdout). Never raises on git failure. @@ -62,14 +37,15 @@ def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT): the diff-rendering subcommands so attribute-scoped diff/textconv drivers can't execute either. """ - command = _git_command() + env = noninteractive_git_env(selected_git_env()) + command = shutil.which("git", path=env.get("PATH", "")) if command is None: return 127, "" proc = subprocess.run( - [*command, "-c", "core.quotePath=false", *harden_git_argv(args)], + [command, "-c", "core.quotePath=false", *harden_git_argv(args)], cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace", - stdin=subprocess.DEVNULL, env=noninteractive_git_env(), + stdin=subprocess.DEVNULL, env=env, ) return proc.returncode, proc.stdout @@ -109,12 +85,12 @@ def collect_working_diff(cwd: str, mode: str = "working", return {"success": False, "error": f"Unknown mode '{mode}'. Use: {', '.join(VALID_MODES)}"} - if _git_command() is None: - return {"success": False, "error": "git is not installed or not on PATH."} try: code, _ = _run(["rev-parse", "--is-inside-work-tree"], cwd, timeout=5) except (subprocess.TimeoutExpired, OSError) as e: return {"success": False, "error": f"git failed: {e}"} + if code == 127: + return {"success": False, "error": "git is not installed or not on PATH."} if code != 0: return {"success": False, "error": "Not a git repository."} From 4e198ec6f809aa15d100312de6f1d8d5952365ac Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:03:17 -0400 Subject: [PATCH 13/33] Share desktop shutdown completion and SQLite snapshot ownership --- apps/desktop/electron/backend-child.ts | 31 +-- .../electron/backend-stop-overlap.test.ts | 89 ++++++++ .../electron/local-backend-lifecycle.ts | 37 ++-- apps/desktop/electron/main.ts | 191 ++++-------------- apps/desktop/electron/pool-stop.test.ts | 30 ++- apps/desktop/electron/pool-stop.ts | 9 +- .../desktop/electron/updater/app-installer.ts | 71 +++---- apps/desktop/electron/updater/mac.test.ts | 65 ++++-- apps/desktop/electron/updater/mac.ts | 77 ++++--- .../electron/updater/packaged-handoff.ts | 62 ++++++ .../updater/state-db-preflight.test.ts | 88 ++++++++ .../electron/updater/state-db-preflight.ts | 31 +++ apps/desktop/electron/updater/store.test.ts | 14 ++ apps/desktop/electron/updater/store.ts | 100 ++++----- .../electron/windows-child-options.test.ts | 39 ++-- hermes_cli/backup.py | 159 ++------------- hermes_cli/backup_sqlite.py | 102 ++++++++++ tests/hermes_cli/test_backup.py | 6 +- tests/hermes_cli/test_backup_preflight.py | 55 +++++ .../test_backup_zip_serialization.py | 50 +++++ 20 files changed, 801 insertions(+), 505 deletions(-) create mode 100644 apps/desktop/electron/backend-stop-overlap.test.ts create mode 100644 apps/desktop/electron/updater/packaged-handoff.ts create mode 100644 apps/desktop/electron/updater/state-db-preflight.test.ts create mode 100644 apps/desktop/electron/updater/state-db-preflight.ts create mode 100644 hermes_cli/backup_sqlite.py create mode 100644 tests/hermes_cli/test_backup_preflight.py create mode 100644 tests/hermes_cli/test_backup_zip_serialization.py diff --git a/apps/desktop/electron/backend-child.ts b/apps/desktop/electron/backend-child.ts index 46c901ddf1..34eb0cc0ea 100644 --- a/apps/desktop/electron/backend-child.ts +++ b/apps/desktop/electron/backend-child.ts @@ -33,13 +33,6 @@ export interface StopBackendChildDeps { killGroup?: (pgid: number, signal: string) => void } -export interface StopBackendTreesForUpdateDeps { - /** Synchronous Windows taskkill /T /F implementation. */ - forceKillProcessTree: (pid: number) => void - /** Clears and stops the desktop's pooled backends. */ - stopAllPoolBackends: () => void -} - export interface BackendProcessRoot { pid?: number | null } @@ -122,13 +115,13 @@ export async function waitForBackendExit( * throws (the process may already be gone) -- mirrors the original inline * best-effort semantics in main.ts. */ -export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) { +export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps): void { if (!child || child.killed) { return } const isWindows = deps.isWindows ?? process.platform === 'win32' - const killGroup = deps.killGroup ?? ((pgid: number, signal: string) => process.kill(pgid, signal)) + const killGroup = deps.killGroup ?? ((pgid: number, signal: string): boolean => process.kill(pgid, signal)) try { if (isWindows && Number.isInteger(child.pid)) { @@ -148,23 +141,3 @@ export function stopBackendChild(child: KillableChild | null | undefined, deps: // Already gone. } } - -/** - * Stop every backend tree owned by a Windows Desktop update hand-off. - * - * Tree-kill the primary root while its PID is still live, then delegate pool - * teardown to the existing routine that tree-kills each pooled root exactly - * once before mutating its registry. In particular, do not signal the primary - * first: if that root exits before taskkill /T runs, Windows can no longer - * enumerate its MCP grandchildren and they survive with the venv locked. - */ -export function stopBackendTreesForUpdate( - primary: BackendProcessRoot | null | undefined, - deps: StopBackendTreesForUpdateDeps -): void { - if (primary && Number.isInteger(primary.pid)) { - deps.forceKillProcessTree(primary.pid as number) - } - - deps.stopAllPoolBackends() -} diff --git a/apps/desktop/electron/backend-stop-overlap.test.ts b/apps/desktop/electron/backend-stop-overlap.test.ts new file mode 100644 index 0000000000..09eaca82ad --- /dev/null +++ b/apps/desktop/electron/backend-stop-overlap.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { type ChildProcess, spawn } from 'node:child_process' +import { once } from 'node:events' + +import { test } from 'vitest' + +import { stopBackendChild, waitForBackendExit } from './backend-child' +import { createLocalBackendLifecycle } from './local-backend-lifecycle' +import { releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator' +import { createPoolStopper } from './pool-stop' + +test.skipIf(process.platform === 'win32')( + 'eviction, update and quit share physical exit before releasing a slot', + async (): Promise => { + let signals = 0 + let released = false + + const physical = { + forceKillProcessTree: (): never => { + throw new Error('POSIX test') + } + } + + const lifecycle = createLocalBackendLifecycle({ + cancelSetup: (): void => {}, + stopChild: (child: ChildProcess): void => { + signals++ + stopBackendChild(child, physical) + }, + waitForExit: (child: ChildProcess): Promise => waitForBackendExit(child, physical) + }) + + const child = lifecycle.spawn((): ChildProcess => + spawn( + process.execPath, + [ + '-e', + ` + process.on('SIGTERM', () => process.send('stopping')); + process.on('message', () => process.exit(0)); + setInterval(() => {}, 1000); + process.send('ready'); + ` + ], + { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] } + ) + ) + + child.once('exit', (): boolean => lifecycle.release(child)) + + const deps = { + pool: new Map([['profile', { process: child }]]), + stopChild: lifecycle.stop + } + + const pool = createPoolStopper(deps) + + try { + await once(child, 'message') + const signalled = once(child, 'message') + + const eviction = releaseLocalBackendSlotAfterExit( + (): void => { + released = true + }, + (): Promise => pool.stop('profile') + ) + + const update = lifecycle.stop(child) + const quit = lifecycle.shutdown() + await signalled + assert.equal(signals, 1) + assert.equal(released, false, 'a pool slot must remain occupied while its process is alive') + assert.ok(pool.inFlight('profile')) + assert.throws((): ChildProcess => lifecycle.spawn((): ChildProcess => child)) + child.send('exit') + await Promise.all([eviction, update, quit]) + assert.equal(child.exitCode, 0) + assert.equal(released, true) + assert.equal(pool.inFlight('profile'), undefined) + assert.equal(lifecycle.hasPending(), false) + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + } + } +) diff --git a/apps/desktop/electron/local-backend-lifecycle.ts b/apps/desktop/electron/local-backend-lifecycle.ts index 49f52d5476..fecacb3707 100644 --- a/apps/desktop/electron/local-backend-lifecycle.ts +++ b/apps/desktop/electron/local-backend-lifecycle.ts @@ -7,7 +7,7 @@ export async function waitForTeardown(tasks: readonly Promise[], timeou try { await Promise.race([ Promise.allSettled(tasks), - new Promise(resolve => { + new Promise((resolve: () => void): void => { timer = setTimeout(resolve, timeoutMs) }) ]) @@ -29,7 +29,20 @@ interface LocalBackendLifecycleDeps { * shutdown signal and synchronous spawn fence make bounded waiting safe: a late * resolver can finish, but can never create another owned backend. */ -export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDeps) { +export interface LocalBackendLifecycle { + signal: AbortSignal + assertCanStart: () => void + hasPending: () => boolean + start: (run: () => Promise) => Promise + spawn: (create: () => Child) => Child + release: (child: Child) => boolean + stop: (child: Child | null | undefined) => Promise + shutdown: () => Promise +} + +export function createLocalBackendLifecycle( + deps: LocalBackendLifecycleDeps +): LocalBackendLifecycle { const controller = new AbortController() const starts = new Set>() const children = new Set() @@ -46,21 +59,21 @@ export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDe return existing } - const stopping = (async () => { + const stopping = (async (): Promise => { deps.stopChild(child) await deps.waitForExit(child) })() stops.set(child, stopping) void stopping.then( - () => stops.delete(child), - () => stops.delete(child) + (): boolean => stops.delete(child), + (): boolean => stops.delete(child) ) return stopping } - const shutdown = createBackendShutdownCoordinator(() => { + const shutdown = createBackendShutdownCoordinator((): Promise => { controller.abort(new Error('Hermes Desktop is quitting.')) deps.cancelSetup() @@ -69,15 +82,15 @@ export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDe return { signal: controller.signal, - assertCanStart: () => controller.signal.throwIfAborted(), - hasPending: () => starts.size > 0 || children.size > 0 || stops.size > 0 || shutdown.isPending(), + assertCanStart: (): void => controller.signal.throwIfAborted(), + hasPending: (): boolean => starts.size > 0 || children.size > 0 || stops.size > 0 || shutdown.isPending(), start(run: () => Promise): Promise { if (controller.signal.aborted) { return Promise.reject(controller.signal.reason) } // Defer invocation one microtask so the inventory precedes all work. - const promise = Promise.resolve().then(() => { + const promise = Promise.resolve().then((): Promise => { controller.signal.throwIfAborted() return run() @@ -85,8 +98,8 @@ export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDe starts.add(promise) void promise.then( - () => starts.delete(promise), - () => starts.delete(promise) + (): boolean => starts.delete(promise), + (): boolean => starts.delete(promise) ) return promise @@ -98,7 +111,7 @@ export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDe return child }, - release: (child: Child) => children.delete(child), + release: (child: Child): boolean => children.delete(child), stop, shutdown: shutdown.run } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d802875b69..a735fc591a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -37,7 +37,7 @@ import { appIconCandidates, resolveAppIcon } from './app-icon' import { stageAppInstallerFile } from './app-installer-file' import { appVersionInfo, type AppVersionInfo, assertSourceUpdateChannel, packagedReleaseChannel } from './app-version' import { runAppInstallerChecker } from './appinstaller-checker' -import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate, waitForBackendExit as waitForBackendExitImpl } from './backend-child' +import { stopBackendChild as stopBackendChildImpl, waitForBackendExit } from './backend-child' import { type BackendOutputTail, claimDecision, @@ -59,7 +59,7 @@ import { makeUnsignedOauthError, waitForHermesReady } from './backend-health' -import { backendCommandMatches, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership' +import { backendCommandMatches, type BackendOwnershipEntry, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership' import { canImportHermesCli, execProbeSync, @@ -433,6 +433,7 @@ import { ExternalStrategy } from './updater/external' import { createMacStrategy } from './updater/mac-client' import { type ConsumedRelaunch, consumePendingRelaunch, registerUpdateRelaunch, type RelaunchRegistration } from './updater/relaunch' import { startRelaunchWaiter } from './updater/relaunch-waiter' +import { preflightStateDb } from './updater/state-db-preflight' import { createStoreStrategy } from './updater/store-client' import { isHermesOwnedVenvDaemon } from './venv-holder-select' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' @@ -1317,7 +1318,7 @@ const localBackendLifecycle = createLocalBackendLifecycle({ stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS }) } }, - waitForExit: (child: ChildProcess): Promise => waitForBackendExit(child), + waitForExit: (child: ChildProcess): Promise => waitForBackendExit(child, { forceKillProcessTree, isWindows: IS_WINDOWS }), cancelSetup: (): void => { firstRunSetupGate?.resetForRetry() bootstrapAbortController?.abort() @@ -3159,7 +3160,10 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { startHermes, stopBackendsForUpdate, repairMacUpdaterHelper, - preflightStateDb, + preflightStateDb: (home: string, log: (message: string) => void): void => { + const root: string = resolveUpdateRoot() + preflightStateDb({ python: findPythonForRoot(root), script: path.join(root, 'hermes_cli', 'backup_sqlite.py'), home, log }) + }, runningAppBundle, markQuittingForHandoff: () => { isQuittingForHandoff = true @@ -3591,7 +3595,13 @@ const desktopParentStartMarker = createParentStartMarkerResolver({ } }) -async function claimBackendChild(child, command, profile, nonce, outputTail: BackendOutputTail | null = null) { +async function claimBackendChild( + child: ChildProcess & { hermesBackendIdentity?: BackendOwnershipEntry }, + command: string, + profile: string, + nonce: string, + outputTail: BackendOutputTail | null = null +): Promise { // Probe/claim policy lives in backend-claim.ts (#93608): a marker probe // that fails against a LIVE child degrades to PID-only identity — matching // createParentStartMarkerResolver — instead of killing a healthy backend @@ -3601,8 +3611,7 @@ async function claimBackendChild(child, command, profile, nonce, outputTail: Bac const decision = claimDecision(child.exitCode === null && !child.killed, probe) if (decision.action === 'fail') { - stopBackendChild(child) - await waitForBackendExit(child) + await localBackendLifecycle.stop(child) throw new Error( `Hermes backend (PID ${child.pid}) died before its identity could be recorded: ${decision.reason}${outputTail?.describe() ?? ''}` ) @@ -3638,8 +3647,7 @@ async function claimBackendChild(child, command, profile, nonce, outputTail: Bac return identity } catch (error) { - stopBackendChild(child) - await waitForBackendExit(child) + await localBackendLifecycle.stop(child) throw new Error( `Could not persist ownership for the Hermes backend: ${error.message}${outputTail?.describe() ?? ''}` ) @@ -3683,16 +3691,13 @@ function reapOrphanedBackendsOnce() { // `hermes update`; neither venv scans nor a second fleet stop belong here. async function stopBackendsForUpdate(): Promise { if (IS_WINDOWS) { - stopBackendTreesForUpdate(backendConnectionState.getProcess(), { - forceKillProcessTree, - stopAllPoolBackends - }) + await Promise.all([teardownPrimaryBackendAndWait(), stopAllPoolBackends()]) } } // Uninstall still deletes the installation and its historical venv. Unlike // generation updates, deletion must wait for those old files to be released. -async function releaseBackendLock(updateRoot, tag) { +async function releaseBackendLock(updateRoot: string, tag: string): Promise<{ unlocked: boolean }> { if (!IS_WINDOWS) { return { unlocked: true } } @@ -3717,10 +3722,7 @@ async function releaseBackendLock(updateRoot, tag) { } } - stopBackendTreesForUpdate(hermesProcess, { - forceKillProcessTree, - stopAllPoolBackends - }) + await Promise.all([teardownPrimaryBackendAndWait(), stopAllPoolBackends()]) // Uninstall deletes the whole runtime. Drain separately-running gateways // through the CLI, rather than targeting a gateway worker by PID. @@ -3934,92 +3936,6 @@ function runningAppBundle() { return dir.endsWith('.app') ? dir : null } -// ── Pre-flight state.db integrity guard (#68474) ───────────────────── -// Take an emergency snapshot of state.db and verify the live copy is -// intact before any update process mutates the install. Runs in the -// desktop Electron process itself, before the backend is killed and -// before the updater is spawned — a separate safety net from the -// Python-level pre-update snapshot inside `hermes update`. -function preflightStateDb(hermesHome, rememberLog) { - const stateDbPath = path.join(hermesHome, 'state.db') - - if (!fileExists(stateDbPath)) { - rememberLog('[updates] state.db pre-flight: not found (fresh install?)') - - return - } - - try { - const stat = fs.statSync(stateDbPath) - - if (stat.size > 100) { - const fd = fs.openSync(stateDbPath, 'r') - const header = Buffer.alloc(16) - - fs.readSync(fd, header, 0, 16, 0) - fs.closeSync(fd) - - const expectedHeader = Buffer.from('SQLite format 3\0') - const headerOk = header.equals(expectedHeader) - - rememberLog( - `[updates] state.db pre-flight: size=${stat.size}, ` + - `headerOk=${headerOk}, headerHex=${header.toString('hex')}` - ) - - if (!headerOk) { - rememberLog( - '[updates] state.db header is INVALID before update — ' + - 'this indicates pre-existing corruption or a concurrent write issue' - ) - } - - // Emergency timestamped backup, separate from the Python-level snapshot. - const ts = new Date().toISOString().replace(/[:.]/g, '-') - - const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`) - - try { - fs.copyFileSync(stateDbPath, emergencyPath) - const emergStat = fs.statSync(emergencyPath) - - rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`) - - // Prune to the 2 most recent emergency backups. - try { - const homeDir = fs.readdirSync(hermesHome) - - const backups = homeDir - .filter( - f => - f.startsWith('state.db.pre-update-emergency-') && - f.endsWith('.bak') && - f !== path.basename(emergencyPath) - ) - .sort() - .reverse() - - for (const old of backups.slice(2)) { - try { - fs.unlinkSync(path.join(hermesHome, old)) - } catch { - void 0 - } - } - } catch { - void 0 - } - } catch (copyErr) { - rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`) - } - } else { - rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`) - } - } catch (statErr) { - rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`) - } -} - // macOS/Linux update hand-off: spawn the repo-owned posix orchestrator // (scripts/desktop-update/posix.sh) detached and QUIT. The script waits us // out, runs `hermes update`, swaps/relaunches the app bundle, and writes @@ -10752,20 +10668,13 @@ function resetBootProgressForReconnect() { ) } -function stopBackendChild(child: ChildProcess | null | undefined): void { - void localBackendLifecycle.stop(child).catch((error: unknown): void => rememberLog(`Backend teardown failed: ${error instanceof Error ? error.message : String(error)}`)) -} - -// Soft gateway-mode apply: tear down the primary without resetting boot UI or -// reloading the renderer. The shell stays up; the renderer wipes session lists -// (so skeletons retrigger) and re-dials. Distinct from hard re-home (profile -// switch / crash recovery), which still resets boot progress + reloads. -function resetHermesConnection({ soft = false } = {}) { +// Reset routing and UI state only. Local callers must await physical teardown. +// Remote revalidation has no local child and can reset this state directly. +function resetHermesConnectionState({ soft = false }: { soft?: boolean } = {}): void { backendStartFailure = null remoteReauthFailure = null remoteLiveness.clear() - const hermesProcess = backendConnectionState.invalidate() - stopBackendChild(hermesProcess) + backendConnectionState.invalidate() if (!soft) { resetBootProgressForReconnect() @@ -10784,7 +10693,7 @@ async function teardownPrimaryBackendAndWait({ soft = false }: { soft?: boolean } try { - resetHermesConnection({ soft }) + resetHermesConnectionState({ soft }) await stopping } finally { if (soft) { @@ -10822,29 +10731,6 @@ function broadcastConnectionsChanged(payload: { connectionId: string; reason: 'r } } -const backendExitWaits = new Map>() - -function waitForBackendExit(child: ChildProcess | null | undefined, timeoutMs: number = 5000): Promise { - if (!child) { - return Promise.resolve() - } - - const existing = backendExitWaits.get(child) - - if (existing) { - return existing - } - - const waiting = waitForBackendExitImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS }, timeoutMs) - backendExitWaits.set(child, waiting) - void waiting.then( - (): boolean => backendExitWaits.delete(child), - (): boolean => backendExitWaits.delete(child) - ) - - return waiting -} - // The profile the primary (window) backend runs as. readActiveDesktopProfile() // returns the desktop's stored preference, or null when unset (legacy launch // that defers to active_profile / default). @@ -11897,14 +11783,9 @@ function teardownFailedLocalBackend(poolKey: string, entry: any): Promise const child = entry.process const teardown = releaseLocalBackendSlotAfterExit( - () => releaseLocalBackendSlot(entry), - async () => { - stopBackendChild(child) - await waitForBackendExit(child) - - if (child && child.exitCode === null && child.signalCode === null) { - throw new Error(`Profile backend for "${poolKey}" did not exit; keeping the local slot occupied.`) - } + (): void => releaseLocalBackendSlot(entry), + async (): Promise => { + await localBackendLifecycle.stop(child) releaseBackendChild(child) } @@ -12190,13 +12071,12 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po // Bounded, deduplicated pool teardown (see pool-stop.ts): every stop path — // idle reaper, LRU eviction, profile delete/rename, quit — shares one // in-flight stop per key and retains the process handle until the bounded -// SIGTERM -> SIGKILL escalation in waitForBackendExit() resolves. Previously +// physical shutdown promise resolves. Previously // SIGTERM + immediate entry delete dropped the handle and a slow child // survived detached under PID 1. const poolStopper = createPoolStopper({ pool: backendPool, - stopChild: child => stopBackendChild(child), - waitForExit: child => waitForBackendExit(child) + stopChild: localBackendLifecycle.stop }) async function stopPoolBackend(profile: string) { @@ -12333,7 +12213,7 @@ async function prepareProfileRenameRequest(request) { }) } -async function startHermes() { +async function startHermes(): Promise>> { // Only the single-instance lock holder may reap/spawn/claim the desktop // backend. A lock-losing instance must stay inert even if some path reaches // here (e.g. the deferred-quit window before `ready`): its reapOrphans() @@ -12588,8 +12468,7 @@ async function startHermes() { )) if (!processOwner) { - stopBackendChild(hermesProcess) - await waitForBackendExit(hermesProcess) + await localBackendLifecycle.stop(hermesProcess) releaseBackendChild(hermesProcess) throw new Error('Hermes backend start was superseded by a newer connection attempt.') } @@ -14421,7 +14300,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { currentConnectionPromise: () => backendConnectionState.getPromise(), log: rememberLog, probe: (connection, path, options) => fetchJsonForBackend(connection, path, options), - resetConnection: () => resetHermesConnection({ soft: true }), + resetConnection: () => resetHermesConnectionState({ soft: true }), tracker: remoteLiveness }), revalidatePool() @@ -14686,7 +14565,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { return { ok: true } }) -ipcMain.handle('hermes:bootstrap:repair', async () => { +ipcMain.handle('hermes:bootstrap:repair', async (): Promise<{ ok: boolean; bundled?: boolean; error?: string }> => { // A bundled install's payload is immutable and sealed at build time — // "repair" would re-run the installer against a separate // %LOCALAPPDATA%\hermes tree the app doesn't own. The only repair for a @@ -14745,7 +14624,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { backendStartFailure = null remoteReauthFailure = null getFirstRunSetupGate().resetForRepair() - resetHermesConnection() + await teardownPrimaryBackendAndWait() return { ok: true } }) diff --git a/apps/desktop/electron/pool-stop.test.ts b/apps/desktop/electron/pool-stop.test.ts index ce497cbad0..c6cc06c63c 100644 --- a/apps/desktop/electron/pool-stop.test.ts +++ b/apps/desktop/electron/pool-stop.test.ts @@ -13,25 +13,31 @@ interface Child { killed: boolean } -function harness() { - const pool = new Map() +function harness(): { + addChild: (key: string) => Child + events: string[] + exitResolvers: Map void> + pool: Map> + stopper: ReturnType> +} { + const pool = new Map>() const events: string[] = [] const exitResolvers = new Map void>() const stopper = createPoolStopper({ pool, - stopChild: child => { + stopChild: (child: Child | undefined): Promise => { ;(child as Child).killed = true events.push('stop') - }, - waitForExit: child => - new Promise(resolve => { - exitResolvers.set(child as Child, () => { + + return new Promise((resolve: () => void): void => { + exitResolvers.set(child as Child, (): void => { ;(child as Child).exited = true events.push('exit') resolve() }) }) + } }) function addChild(key: string): Child { @@ -181,9 +187,13 @@ test('failed stops block respawn and retain the child for a later stop retry', a const stopper = createPoolStopper({ pool, - stopChild: current => { attempts.push(current!) }, - waitForExit: async current => { - if (refuses) { throw failure } + stopChild: async (current: Child | undefined): Promise => { + attempts.push(current!) + + if (refuses) { + throw failure + } + current!.exited = true } }) diff --git a/apps/desktop/electron/pool-stop.ts b/apps/desktop/electron/pool-stop.ts index beca9466c5..551c67f7b2 100644 --- a/apps/desktop/electron/pool-stop.ts +++ b/apps/desktop/electron/pool-stop.ts @@ -29,10 +29,8 @@ export interface PoolStopEntry { export interface PoolStopperDeps { /** The live backend pool. Entries are evicted synchronously on stop. */ pool: Map> - /** Signal the child (tree/group kill per platform). Synchronous. */ - stopChild: (child: Process | undefined) => void - /** Bounded wait: resolves when the child exits, escalating to SIGKILL. */ - waitForExit: (child: Process | undefined) => Promise + /** The physical lifecycle owns signalling, escalation and confirmed exit. */ + stopChild: (child: Process | undefined) => Promise } export interface PoolStopper { @@ -73,8 +71,7 @@ export function createPoolStopper(deps: PoolStopperDeps): Pool deps.pool.delete(key) const stopping = (async (): Promise => { - deps.stopChild(entry.process) - await deps.waitForExit(entry.process) + await deps.stopChild(entry.process) })().then( (): void => { stops.delete(key) diff --git a/apps/desktop/electron/updater/app-installer.ts b/apps/desktop/electron/updater/app-installer.ts index edaba628b8..21c86c0452 100644 --- a/apps/desktop/electron/updater/app-installer.ts +++ b/apps/desktop/electron/updater/app-installer.ts @@ -19,6 +19,7 @@ import { win32AppInstallerFeedPath } from '../app-updater' +import { applyPackagedHandoff } from './packaged-handoff' import type { RelaunchRegistration } from './relaunch' import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index' @@ -106,57 +107,35 @@ export class AppInstallerStrategy { percent: 100 }) - let registration: RelaunchRegistration | undefined - let teardownStarted = false - - try { - await triggerAppInstallerUpdate( - feedBaseUrl, - this.deps.channel, - this.deps.light, - this.deps.installer, - async () => { - registration = await this.deps.registerPendingRelaunch(this.deps.appVersion) - - if (!registration.automatic) { + return applyPackagedHandoff( + { + teardown: this.deps.teardownBundledBackend, + restore: this.deps.restoreBundledBackend, + emitProgress: this.deps.emitUpdateProgress, + relaunch: { + register: (): Promise => this.deps.registerPendingRelaunch(this.deps.appVersion), + onManual: (): void => this.deps.emitUpdateProgress({ - stage: 'restart', percent: 100, + stage: 'restart', + percent: 100, message: 'Automatic relaunch could not be registered. Reopen Hermes after App Installer finishes.' }) - } - - teardownStarted = true - await this.deps.teardownBundledBackend() - }, - sourceUri - ) - - this.deps.quit() - } catch (error) { - const errors: unknown[] = [error] - - try { - await registration?.cancel() - } catch (cancelError) { - errors.push(cancelError) - } - - if (teardownStarted) { - try { - await this.deps.restoreBundledBackend() - } catch (restoreError) { - errors.push(restoreError) } + }, + async (stop: () => Promise): Promise => { + await triggerAppInstallerUpdate( + feedBaseUrl, + this.deps.channel, + this.deps.light, + this.deps.installer, + stop, + sourceUri + ) + this.deps.quit() + + return { ok: true, manual: false, bundled: true, handedOff: true, mechanism: this.mechanism } } - - const message = errors.map(item => item instanceof Error ? item.message : String(item)).join('; ') - this.deps.emitUpdateProgress({ stage: 'error', message, percent: null }) - - if (errors.length > 1) { throw new AggregateError(errors, message, { cause: error }) } - throw error - } - - return { ok: true, manual: false, bundled: true, handedOff: true, mechanism: this.mechanism } + ) } } diff --git a/apps/desktop/electron/updater/mac.test.ts b/apps/desktop/electron/updater/mac.test.ts index 19ec867cb1..c105d04252 100644 --- a/apps/desktop/electron/updater/mac.test.ts +++ b/apps/desktop/electron/updater/mac.test.ts @@ -16,18 +16,28 @@ function fixture() { return { isUpdateAvailable: true, updateInfo: info, versionInfo: info } }), - downloadUpdate: vi.fn(async () => { events.push('download'); + downloadUpdate: vi.fn(async () => { + events.push('download') - return [] }), - quitAndInstall: vi.fn(() => { events.push('install') }), + return [] + }), + quitAndInstall: vi.fn(() => { + events.push('install') + }), on: emitter.on.bind(emitter) as MacStrategyDeps['updater']['on'], removeListener: emitter.removeListener.bind(emitter) as MacStrategyDeps['updater']['removeListener'] }, channel: 'canary', appVersion: '0.28.0', - prepareInstall: vi.fn(async () => { events.push('verify') }), - beforeInstall: vi.fn(async () => { events.push('stop') }), - onInstallFailure: vi.fn(async () => { events.push('restore') }), + prepareInstall: vi.fn(async () => { + events.push('verify') + }), + beforeInstall: vi.fn(async () => { + events.push('stop') + }), + onInstallFailure: vi.fn(async () => { + events.push('restore') + }), emitProgress: vi.fn() } @@ -48,8 +58,9 @@ describe('macOS strategy', () => { it.each(['downloadUpdate', 'prepareInstall'] as const)('keeps backends alive on %s failure', async failure => { const { deps, strategy, events, emitter } = fixture() - vi.mocked(failure === 'downloadUpdate' ? deps.updater.downloadUpdate : deps.prepareInstall) - .mockRejectedValueOnce(new Error('invalid update')) + vi.mocked(failure === 'downloadUpdate' ? deps.updater.downloadUpdate : deps.prepareInstall).mockRejectedValueOnce( + new Error('invalid update') + ) await expect(strategy.apply()).rejects.toThrow('invalid update') expect(events).not.toContain('stop') expect(events).not.toContain('install') @@ -60,7 +71,9 @@ describe('macOS strategy', () => { const { deps, strategy, events } = fixture() const info = { version: '0.27.0', files: [], releaseDate: '', path: '', sha512: '' } vi.mocked(deps.updater.checkForUpdates).mockResolvedValue({ - isUpdateAvailable: false, updateInfo: info, versionInfo: info + isUpdateAvailable: false, + updateInfo: info, + versionInfo: info }) await strategy.apply() expect(events).toEqual([]) @@ -69,15 +82,39 @@ describe('macOS strategy', () => { it('restores the backend if install handoff throws', async () => { const { deps, strategy, events } = fixture() - vi.mocked(deps.updater.quitAndInstall).mockImplementation(() => { throw new Error('handoff failed') }) + vi.mocked(deps.updater.quitAndInstall).mockImplementation(() => { + throw new Error('handoff failed') + }) await expect(strategy.apply()).rejects.toThrow('handoff failed') expect(events.slice(-2)).toEqual(['stop', 'restore']) }) + it('preserves the handoff error when backend recovery also fails', async (): Promise => { + const { deps, strategy, emitter } = fixture() + const handoff = new Error('native handoff failed') + const recovery = new Error('backend recovery failed') + vi.mocked(deps.updater.quitAndInstall).mockImplementation((): never => { + throw handoff + }) + vi.mocked(deps.onInstallFailure).mockRejectedValue(recovery) + await expect(strategy.apply()).rejects.toMatchObject({ cause: handoff, errors: [handoff, recovery] }) + expect(deps.emitProgress).toHaveBeenLastCalledWith({ + stage: 'error', + message: 'native handoff failed; backend recovery failed', + percent: null + }) + expect(emitter.listenerCount('download-progress')).toBe(0) + }) + it('rejects simultaneous apply calls', async () => { const { deps, strategy } = fixture() let release!: () => void - vi.mocked(deps.prepareInstall).mockImplementation(() => new Promise(resolve => { release = resolve })) + vi.mocked(deps.prepareInstall).mockImplementation( + () => + new Promise(resolve => { + release = resolve + }) + ) const applying = strategy.apply() await vi.waitFor(() => expect(deps.prepareInstall).toHaveBeenCalledOnce()) await expect(strategy.apply()).rejects.toThrow('already in progress') @@ -91,7 +128,11 @@ describe('native signature verification', () => { it('waits for native readiness and removes both listeners', async () => { const native = Object.assign(new EventEmitter(), { checkForUpdates: vi.fn() }) let ready = false - const pending = prepareMacInstall(native).then(() => { ready = true }) + + const pending = prepareMacInstall(native).then(() => { + ready = true + }) + await Promise.resolve() expect(ready).toBe(false) native.emit('update-downloaded') diff --git a/apps/desktop/electron/updater/mac.ts b/apps/desktop/electron/updater/mac.ts index 12ddfc17f7..03a4df8f7b 100644 --- a/apps/desktop/electron/updater/mac.ts +++ b/apps/desktop/electron/updater/mac.ts @@ -1,5 +1,7 @@ import type { AppUpdater } from 'electron-updater' +import { applyPackagedHandoff } from './packaged-handoff' + import type { UpdaterApplyResultWire, UpdaterStatusWire, UpdaterStrategy } from './index' export interface MacStrategyDeps { @@ -20,7 +22,9 @@ export class MacStrategy implements UpdaterStrategy { constructor(private readonly deps: MacStrategyDeps) {} async check(): Promise { - if (this.applying) { throw new Error('An update is already in progress.') } + if (this.applying) { + throw new Error('An update is already in progress.') + } return this.checkRelease() } @@ -28,7 +32,9 @@ export class MacStrategy implements UpdaterStrategy { private async checkRelease(): Promise { const result = await this.deps.updater.checkForUpdates() - if (!result) { throw new Error('The macOS updater is not active for this app.') } + if (!result) { + throw new Error('The macOS updater is not active for this app.') + } return { supported: true, @@ -42,9 +48,11 @@ export class MacStrategy implements UpdaterStrategy { } async apply(): Promise { - if (this.applying) { throw new Error('An update is already in progress.') } + if (this.applying) { + throw new Error('An update is already in progress.') + } + this.applying = true - let stopped = false const progress = ({ percent }: { percent: number }): void => { this.deps.emitProgress({ stage: 'fetch', message: 'Downloading the Hermes update.', percent }) @@ -53,21 +61,33 @@ export class MacStrategy implements UpdaterStrategy { this.deps.updater.on('download-progress', progress) try { - const status = await this.checkRelease() + return await applyPackagedHandoff( + { + teardown: this.deps.beforeInstall, + restore: this.deps.onInstallFailure, + emitProgress: this.deps.emitProgress + }, + async (stop: () => Promise): Promise => { + const status = await this.checkRelease() - if (!status.updateAvailable) { return { ok: true, mechanism: this.mechanism } } - await this.deps.updater.downloadUpdate() - this.deps.emitProgress({ stage: 'prepare', message: 'Verifying the signed macOS update.', percent: null }) - await this.deps.prepareInstall() - stopped = true - await this.deps.beforeInstall() - this.deps.emitProgress({ stage: 'restart', message: 'Restarting Hermes to install the update.', percent: 100 }) - this.deps.updater.quitAndInstall() + if (!status.updateAvailable) { + return { ok: true, mechanism: this.mechanism } + } - return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } - } catch (error) { - if (stopped) { await this.deps.onInstallFailure() } - throw error + await this.deps.updater.downloadUpdate() + this.deps.emitProgress({ stage: 'prepare', message: 'Verifying the signed macOS update.', percent: null }) + await this.deps.prepareInstall() + await stop() + this.deps.emitProgress({ + stage: 'restart', + message: 'Restarting Hermes to install the update.', + percent: 100 + }) + this.deps.updater.quitAndInstall() + + return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } + } + ) } finally { this.deps.updater.removeListener('download-progress', progress) this.applying = false @@ -84,21 +104,32 @@ export interface NativeMacUpdater { } /** Download completion alone does not mean Squirrel accepted the signature. */ -export function prepareMacInstall(native: NativeMacUpdater, timeoutMs = 120_000): Promise { - return new Promise((resolve, reject) => { +export function prepareMacInstall(native: NativeMacUpdater, timeoutMs: number = 120_000): Promise { + return new Promise((resolve: () => void, reject: (error: Error) => void): void => { const cleanup = (): void => { clearTimeout(timer) native.removeListener('error', failed) native.removeListener('update-downloaded', ready) } - const failed = (error: Error): void => { cleanup(); reject(error) } + const failed = (error: Error): void => { + cleanup() + reject(error) + } - const ready = (): void => { cleanup(); resolve() } - const timer = setTimeout(() => failed(new Error('macOS update verification timed out.')), timeoutMs) + const ready = (): void => { + cleanup() + resolve() + } + + const timer = setTimeout((): void => failed(new Error('macOS update verification timed out.')), timeoutMs) native.once('error', failed) native.once('update-downloaded', ready) - try { native.checkForUpdates() } catch (error) { failed(error as Error) } + try { + native.checkForUpdates() + } catch (error) { + failed(error as Error) + } }) } diff --git a/apps/desktop/electron/updater/packaged-handoff.ts b/apps/desktop/electron/updater/packaged-handoff.ts new file mode 100644 index 0000000000..8e0fa62e3c --- /dev/null +++ b/apps/desktop/electron/updater/packaged-handoff.ts @@ -0,0 +1,62 @@ +import type { RelaunchRegistration } from './relaunch' + +import type { UpdaterApplyResultWire } from './index' + +interface PackagedHandoffDeps { + teardown: () => void | Promise + restore: () => Promise + emitProgress: (progress: { stage: string; message: string; percent: number | null }) => void + relaunch?: { + register: () => Promise + onManual: () => void + } +} + +/** Native preparation decides when it is safe to stop. Recovery has one owner. */ +export async function applyPackagedHandoff( + deps: PackagedHandoffDeps, + apply: (stop: () => Promise) => Promise +): Promise { + let registration: RelaunchRegistration | undefined + let teardownStarted = false + + const stop = async (): Promise => { + if (deps.relaunch) { + registration = await deps.relaunch.register() + + if (!registration.automatic) { + deps.relaunch.onManual() + } + } + + teardownStarted = true + await deps.teardown() + } + + try { + return await apply(stop) + } catch (error) { + const errors: unknown[] = [error] + + try { + await registration?.cancel() + } catch (cancelError) { + errors.push(cancelError) + } + + if (teardownStarted) { + try { + await deps.restore() + } catch (restoreError) { + errors.push(restoreError) + } + } + + const message = errors + .map((item: unknown): string => (item instanceof Error ? item.message : String(item))) + .join('; ') + + deps.emitProgress({ stage: 'error', message, percent: null }) + throw errors.length > 1 ? new AggregateError(errors, message, { cause: error }) : error + } +} diff --git a/apps/desktop/electron/updater/state-db-preflight.test.ts b/apps/desktop/electron/updater/state-db-preflight.test.ts new file mode 100644 index 0000000000..8f6a42fcc2 --- /dev/null +++ b/apps/desktop/electron/updater/state-db-preflight.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { test } from 'vitest' + +import { preflightStateDb } from './state-db-preflight' + +test('the desktop preflight publishes committed WAL rows before its caller can stop the backend', async (): Promise => { + const home: string = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-db-')) + const python: string = process.env.HERMES_PYTHON || 'python3' + const script: string = fileURLToPath(new URL('../../../../hermes_cli/backup_sqlite.py', import.meta.url)) + + const child = spawn( + python, + [ + '-I', + '-S', + '-u', + '-c', + ` +import sqlite3, sys +c = sqlite3.connect(sys.argv[1]) +c.execute('PRAGMA journal_mode=WAL') +c.execute('PRAGMA wal_autocheckpoint=0') +c.execute('CREATE TABLE messages (body TEXT)') +c.commit() +c.execute('PRAGMA wal_checkpoint(TRUNCATE)') +c.execute("INSERT INTO messages VALUES ('pending in WAL')") +c.commit() +print('ready', flush=True) +sys.stdin.readline() +c.close() +`, + path.join(home, 'state.db') + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + + const logs: string[] = [] + + try { + await once(child.stdout!, 'data') + preflightStateDb({ + python, + script, + home, + log: (message: string): void => { + logs.push(message) + } + }) + assert.equal(child.exitCode, null) + const backups: string[] = fs.readdirSync(home).filter((name: string): boolean => name.endsWith('.bak')) + assert.equal(backups.length, 1, logs.join('\n')) + + const verify = spawnSync( + python, + [ + '-I', + '-S', + '-c', + ` +import sqlite3, sys +with sqlite3.connect(sys.argv[1]) as c: + assert c.execute('SELECT body FROM messages').fetchall() == [('pending in WAL',)] +`, + path.join(home, backups[0]!) + ], + { encoding: 'utf8' } + ) + + assert.equal(verify.status, 0, verify.stderr) + const exited = once(child, 'exit') + child.stdin!.end('\n') + await exited + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + + fs.rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/updater/state-db-preflight.ts b/apps/desktop/electron/updater/state-db-preflight.ts new file mode 100644 index 0000000000..d7c0b5ca62 --- /dev/null +++ b/apps/desktop/electron/updater/state-db-preflight.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process' + +import { hiddenWindowsChildOptions } from '../windows-child-options' + +interface StateDbPreflight { + python: string | null + script: string + home: string + log: (message: string) => void +} + +// Synchronous by design: the caller must not stop the backend before the snapshot. +export function preflightStateDb({ python, script, home, log }: StateDbPreflight): void { + if (!python) { + log('[updates] state.db pre-flight unavailable: Python not found') + + return + } + + try { + const result: string = execFileSync( + python, + ['-I', '-S', script, home], + hiddenWindowsChildOptions({ encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }) + ) + + log(`[updates] state.db pre-flight: ${result.trim()}`) + } catch (error: unknown) { + log(`[updates] state.db pre-flight failed: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/apps/desktop/electron/updater/store.test.ts b/apps/desktop/electron/updater/store.test.ts index 3ed532176f..9098a85fce 100644 --- a/apps/desktop/electron/updater/store.test.ts +++ b/apps/desktop/electron/updater/store.test.ts @@ -38,6 +38,20 @@ function dependencies(failAt?: string): { deps: StoreStrategyDeps; calls: string } describe('Microsoft Store update lifecycle', () => { + it('requires automatic relaunch and cancels registration without stopping the backend', async (): Promise => { + const { deps, calls } = dependencies() + deps.registerPendingRelaunch = async (): Promise< + Awaited> + > => ({ + automatic: false, + cancel: async (): Promise => { + calls.push('cancel') + } + }) + await expect(new StoreStrategy(deps).apply()).rejects.toThrow('Could not register automatic relaunch') + expect(calls).toEqual(['download', 'cancel']) + }) + it('downloads before shutdown, owns relaunch before install, and keeps no-update non-destructive', async () => { const { deps, calls } = dependencies() const strategy = new StoreStrategy(deps) diff --git a/apps/desktop/electron/updater/store.ts b/apps/desktop/electron/updater/store.ts index 48a3b7297e..dd1412c1b4 100644 --- a/apps/desktop/electron/updater/store.ts +++ b/apps/desktop/electron/updater/store.ts @@ -1,3 +1,4 @@ +import { applyPackagedHandoff } from './packaged-handoff' import type { RelaunchRegistration } from './relaunch' import type { UpdaterApplyResultWire, UpdaterStatusWire, UpdaterStrategy } from './index' @@ -40,63 +41,50 @@ export class StoreStrategy implements UpdaterStrategy { } async apply(): Promise { - let registration: RelaunchRegistration | undefined - let stopped = false - - try { - this.deps.emitProgress({ stage: 'fetch', message: 'Downloading the update from Microsoft Store.', percent: null }) - const downloaded = await this.deps.run('download') - - if (!downloaded.ok || downloaded.available === null) { - throw new Error(downloaded.error || 'Microsoft Store download did not complete') - } - - if (!downloaded.available) { - return { ok: true, updateAvailable: false, mechanism: this.mechanism } - } - - registration = await this.deps.registerPendingRelaunch(this.deps.appVersion) - - if (!registration.automatic) { - throw new Error('Could not register automatic relaunch for the Store update') - } - - stopped = true - await this.deps.teardown() - this.deps.emitProgress({ - stage: 'restart', - message: 'Microsoft Store is installing the update. Hermes will reopen.', - percent: null - }) - const installed = await this.deps.run('install') - - if (!installed.ok || installed.available !== true) { - throw new Error(installed.error || 'Microsoft Store did not confirm installation') - } - - this.deps.quit() - - return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } - } catch (error) { - const errors: unknown[] = [error] - - try { - await registration?.cancel() - } catch (cancelError) { - errors.push(cancelError) - } - - if (stopped) { - try { - await this.deps.restore() - } catch (restoreError) { - errors.push(restoreError) + return applyPackagedHandoff( + { + teardown: this.deps.teardown, + restore: this.deps.restore, + emitProgress: this.deps.emitProgress, + relaunch: { + register: (): Promise => this.deps.registerPendingRelaunch(this.deps.appVersion), + onManual: (): never => { + throw new Error('Could not register automatic relaunch for the Store update') + } } - } + }, + async (stop: () => Promise): Promise => { + this.deps.emitProgress({ + stage: 'fetch', + message: 'Downloading the update from Microsoft Store.', + percent: null + }) + const downloaded = await this.deps.run('download') - const message = errors.map(item => (item instanceof Error ? item.message : String(item))).join('; ') - this.deps.emitProgress({ stage: 'error', message, percent: null }) - throw errors.length > 1 ? new AggregateError(errors, message, { cause: error }) : error - } + if (!downloaded.ok || downloaded.available === null) { + throw new Error(downloaded.error || 'Microsoft Store download did not complete') + } + + if (!downloaded.available) { + return { ok: true, updateAvailable: false, mechanism: this.mechanism } + } + + await stop() + this.deps.emitProgress({ + stage: 'restart', + message: 'Microsoft Store is installing the update. Hermes will reopen.', + percent: null + }) + const installed = await this.deps.run('install') + + if (!installed.ok || installed.available !== true) { + throw new Error(installed.error || 'Microsoft Store did not confirm installation') + } + + this.deps.quit() + + return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } + } + ) } } diff --git a/apps/desktop/electron/windows-child-options.test.ts b/apps/desktop/electron/windows-child-options.test.ts index a68b9dfc5e..14a6de355c 100644 --- a/apps/desktop/electron/windows-child-options.test.ts +++ b/apps/desktop/electron/windows-child-options.test.ts @@ -2,7 +2,8 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { stopBackendChild, stopBackendTreesForUpdate } from './backend-child' +import { stopBackendChild } from './backend-child' +import { createLocalBackendLifecycle } from './local-backend-lifecycle' import { hiddenWindowsChildOptions } from './windows-child-options' test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => { @@ -148,21 +149,33 @@ test('stopBackendChild swallows errors thrown by the kill strategy', () => { }) }) -test('Windows update tree-kills captured roots without pre-signalling the primary backend', () => { +test('Windows shutdown tree-kills before waiting, and joins an overlapping stop', async (): Promise => { const primary = makeChild({ pid: 101 }) - const pooled = makeChild({ pid: 202 }) const events: string[] = [] + let exit!: () => void - stopBackendTreesForUpdate(primary.child, { - forceKillProcessTree: pid => events.push(`tree:${pid}`), - stopAllPoolBackends: () => { - events.push('pool-stop') - // Production stopAllPoolBackends() already tree-kills every pool root. - events.push(`tree:${pooled.child.pid}`) - } + const lifecycle = createLocalBackendLifecycle({ + stopChild: (child: typeof primary.child): void => + stopBackendChild(child, { + forceKillProcessTree: (pid: number): void => { + events.push(`tree:${pid}`) + }, + isWindows: true + }), + waitForExit: (): Promise => + new Promise((resolve: () => void): void => { + events.push('wait') + exit = resolve + }), + cancelSetup: (): void => {} }) - assert.deepEqual(events, ['tree:101', 'pool-stop', 'tree:202']) - assert.deepEqual(primary.calls, [], 'the primary root must not be signalled before taskkill /T sees it') - assert.deepEqual(pooled.calls, []) + const child = lifecycle.spawn((): typeof primary.child => primary.child) + const stopped = lifecycle.stop(child) + assert.equal(lifecycle.stop(child), stopped) + const shutdown = lifecycle.shutdown() + assert.deepEqual(events, ['tree:101', 'wait']) + assert.deepEqual(primary.calls, [], 'taskkill must enumerate descendants before the root can exit') + exit() + await Promise.all([stopped, shutdown]) }) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 0fc4e29bf4..78b6735839 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -25,6 +25,7 @@ from utils import ( ) from hermes_cli.archive_safe import normalize_archive_parts +from hermes_cli.backup_sqlite import _close_quietly, _safe_copy_db from hermes_cli.home_data_layout import PM_RUNTIME_ROOT_DIRS, profile_root_entry from hermes_cli.sizefmt import format_bytes as _format_size @@ -139,10 +140,6 @@ class _SQLiteSnapshotError(RuntimeError): pass -class _SQLiteBackupTimeout(RuntimeError): - """Raised when a SQLite snapshot remains busy past its deadline.""" - - @contextmanager def _backup_operation_lock(hermes_home: Path, timeout_seconds: float = 0.25): """Acquire one cross-process backup slot for full and quick snapshots.""" @@ -286,12 +283,6 @@ def _iter_backup_files(hermes_root: Path, out_path: Path, skipped_dirs: Optional # --- SQLite safe copy --- -def _close_quietly(conn: Optional[sqlite3.Connection]) -> None: - if conn is not None: - with suppress(Exception): - conn.close() - - def _query_ro_sqlite(path: Path, fn): """Run ``fn(conn)`` on a read-only connection to *path*; return ``(value, None)`` or ``(None, exc)``.""" conn = None @@ -304,43 +295,6 @@ def _query_ro_sqlite(path: Path, fn): _close_quietly(conn) -def _safe_copy_db(src: Path, dst: Path, *, timeout_seconds: float = 10.0) -> bool: - """Copy a SQLite database with the backup() API (WAL-safe consistent snapshot). - - Fails closed when no consistent snapshot can be made: copying only the main file loses WAL data. - """ - conn = backup_conn = None - try: - # timeout=0.0 disables sqlite3's implicit busy wait so the progress callback owns the - # full locked-source deadline instead of adding the default timeout before each callback. - conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=0.0) - backup_conn = sqlite3.connect(str(dst)) - busy_deadline = time.monotonic() + max(0.0, timeout_seconds) - - def _check_backup_progress(status: int, _remaining: int, _total: int) -> None: - nonlocal busy_deadline - now = time.monotonic() - if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED): - if now >= busy_deadline: - raise _SQLiteBackupTimeout(f"database remained locked for {timeout_seconds:g} seconds") - else: - busy_deadline = now + max(0.0, timeout_seconds) - - conn.backup(backup_conn, pages=256, progress=_check_backup_progress, sleep=0.1) - return True - except Exception as exc: - logger.warning("SQLite safe copy failed for %s: %s", src, exc) - # Windows won't remove the partial destination while SQLite still has it open. - _close_quietly(backup_conn) - backup_conn = None - with suppress(OSError): - dst.unlink(missing_ok=True) - return False - finally: - _close_quietly(backup_conn) - _close_quietly(conn) - - def is_zeroed_sqlite_file(path: Path, *, probe_bytes: int = 100, force: bool = False) -> bool: """True when *path* looks like the #68474 zeroed-state.db signature. @@ -694,28 +648,8 @@ def _run_backup_locked(args, hermes_root: Path) -> None: ) as zf: for i, (abs_path, rel_path) in enumerate(files_to_add, 1): try: - # Safe copy for SQLite databases (handles WAL mode) - if abs_path.suffix == ".db": - # Stage the snapshot alongside the output zip so that the - # temp file lives on the same filesystem. The system - # default (/tmp) may be a small tmpfs that cannot hold - # large databases, causing silent backup incompleteness. - with tempfile.NamedTemporaryFile( - suffix=".db", delete=False, dir=str(out_path.parent) - ) as tmp: - tmp_db = Path(tmp.name) - if _safe_copy_db(abs_path, tmp_db): - zf.write(tmp_db, arcname=str(rel_path)) - total_bytes += tmp_db.stat().st_size - tmp_db.unlink(missing_ok=True) - else: - tmp_db.unlink(missing_ok=True) - errors.append(f" {rel_path}: SQLite safe copy failed") - continue - else: - zf.write(abs_path, arcname=str(rel_path)) - total_bytes += abs_path.stat().st_size - except (PermissionError, OSError, ValueError) as exc: + total_bytes += _write_backup_file(zf, abs_path, str(rel_path), out_path.parent) + except (OSError, ValueError, _SQLiteSnapshotError) as exc: errors.append(f" {rel_path}: {exc}") continue @@ -1113,50 +1047,6 @@ def run_import(args) -> None: # Quick state snapshots (used by /snapshot slash command and hermes backup --quick) # --------------------------------------------------------------------------- -# Critical state files to include in quick snapshots (relative to HERMES_HOME). -# Everything else is either regeneratable (logs, cache) or managed separately -# (skills, repo, sessions/). -# -# Entries may be individual files OR directories. Directories are captured -# recursively; missing entries are silently skipped. Pairing data lives in -# platform-specific JSON blobs outside state.db, so it's listed here explicitly -# — `hermes update` snapshots this set before pulling so approved-user lists -# are recoverable if anything goes wrong (issue #15733). -_QUICK_STATE_FILES = ( - "state.db", - "config.yaml", - ".env", - "auth.json", - "cron/jobs.json", - "cron/executions.db", - "gateway_state.json", - "channel_directory.json", - "channel_aliases.json", - "processes.json", - "gateway/discord_message_recovery.db", # Discord reconnect replay ledger - # Per-profile user-created stores that live outside the git checkout and - # are therefore destroyed if the update flow removes/replaces the file and - # the post-update schema-init re-creates an empty one (issue #52889). All - # are at $HERMES_HOME/ for the default/root profile; on non-root - # profiles the real path is outside HERMES_HOME and the entry is silently - # skipped (best-effort, same as the pairing stores). SQLite DBs are copied - # WAL-safely via _safe_copy_db. - "projects.db", # per-profile project store - "response_store.db", # gateway conversation history / tool payloads - "memory_store.db", # holographic memory facts/entities - "verification_evidence.db", # agent verification audit trail - "kanban.db", # default board (back-compat /kanban.db) - "kanban/boards", # non-default boards: each /kanban.db + board metadata (workspaces/ + attachments/ are skipped as regenerable) - # Pairing stores (generic + per-platform JSONs outside state.db) - "pairing", # legacy location (gateway/pairing.py) - "platforms/pairing", # new location (gateway/pairing.py) - "feishu_comment_pairing.json", # Feishu comment subscription pairings -) - -# ``_QUICK_SNAPSHOTS_DIR`` lives with the exclusion rules at the top of the module. -_QUICK_DEFAULT_KEEP = 20 - - def create_quick_snapshot( label: Optional[str] = None, hermes_home: Optional[Path] = None, @@ -1507,11 +1397,6 @@ def restore_quick_snapshot( return restored > 0 -# Relative path of the cron job database inside HERMES_HOME. Kept in sync with -# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``. -_CRON_JOBS_REL = "cron/jobs.json" - - def _count_cron_jobs(path: Path) -> Optional[int]: """Return the number of cron jobs stored in ``path``. @@ -1934,6 +1819,22 @@ def run_quick_backup(args) -> None: # Shared full-zip backup helper # --------------------------------------------------------------------------- +def _write_backup_file(zf: zipfile.ZipFile, source: Path, arcname: str, staging_dir: Path) -> int: + """Serialize one file, staging SQLite beside the ZIP rather than in small tmpfs.""" + if source.suffix != ".db": + zf.write(source, arcname=arcname) + return source.stat().st_size + with tempfile.NamedTemporaryFile(suffix=".db", delete=False, dir=str(staging_dir)) as tmp: + snapshot = Path(tmp.name) + try: + if not _safe_copy_db(source, snapshot): + raise _SQLiteSnapshotError("SQLite safe copy failed") + zf.write(snapshot, arcname=arcname) + return snapshot.stat().st_size + finally: + snapshot.unlink(missing_ok=True) + + def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]: """Single-flight wrapper for automatic full zip backups.""" try: @@ -1975,27 +1876,7 @@ def _write_full_zip_backup_locked(out_path: Path, hermes_root: Path) -> Optional ) as zf: for index, (abs_path, rel_path) in enumerate(files_to_add, 1): try: - if abs_path.suffix == ".db": - # Stage the snapshot alongside the output zip so that the - # temp file lives on the same filesystem. The system - # default (/tmp) may be a small tmpfs that cannot hold - # large databases, causing silent backup incompleteness. - with tempfile.NamedTemporaryFile( - suffix=".db", delete=False, dir=str(out_path.parent) - ) as tmp: - tmp_db = Path(tmp.name) - try: - if not _safe_copy_db(abs_path, tmp_db): - logger.warning( - "Full-zip backup aborted: SQLite snapshot failed for %s", - rel_path, - ) - raise _SQLiteSnapshotError(str(rel_path)) - zf.write(tmp_db, arcname=str(rel_path)) - finally: - tmp_db.unlink(missing_ok=True) - else: - zf.write(abs_path, arcname=str(rel_path)) + _write_backup_file(zf, abs_path, str(rel_path), out_path.parent) except (PermissionError, OSError, ValueError) as exc: logger.debug("Skipping %s in zip backup: %s", rel_path, exc) continue diff --git a/hermes_cli/backup_sqlite.py b/hermes_cli/backup_sqlite.py new file mode 100644 index 0000000000..7fa1254322 --- /dev/null +++ b/hermes_cli/backup_sqlite.py @@ -0,0 +1,102 @@ +"""WAL-safe SQLite snapshots. Direct execution needs only the standard library. + +Desktop invokes this file before stopping its backend, even when application +imports cannot load. Full and quick backups use the same SQLite copy operation. +""" +import json +import logging +import os +import sqlite3 +import sys +import tempfile +import time +from contextlib import suppress +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class _SQLiteBackupTimeout(RuntimeError): + """Raised when a SQLite snapshot remains busy past its deadline.""" + + +def _close_quietly(conn: Optional[sqlite3.Connection]) -> None: + if conn is not None: + with suppress(Exception): + conn.close() + + +def _safe_copy_db(src: Path, dst: Path, *, timeout_seconds: float = 10.0) -> bool: + """Copy a SQLite database with the backup() API (WAL-safe consistent snapshot). + + Fails closed when no consistent snapshot can be made: copying only the main file loses WAL data. + """ + conn = backup_conn = None + try: + # timeout=0.0 disables sqlite3's implicit busy wait so the progress callback owns the + # full locked-source deadline instead of adding the default timeout before each callback. + conn = sqlite3.connect(f"{src.resolve().as_uri()}?mode=ro", uri=True, timeout=0.0) + backup_conn = sqlite3.connect(str(dst)) + busy_deadline = time.monotonic() + max(0.0, timeout_seconds) + + def _check_backup_progress(status: int, _remaining: int, _total: int) -> None: + nonlocal busy_deadline + now = time.monotonic() + if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED): + if now >= busy_deadline: + raise _SQLiteBackupTimeout(f"database remained locked for {timeout_seconds:g} seconds") + else: + busy_deadline = now + max(0.0, timeout_seconds) + + conn.backup(backup_conn, pages=256, progress=_check_backup_progress, sleep=0.1) + return True + except Exception as exc: + logger.warning("SQLite safe copy failed for %s: %s", src, exc) + # Windows won't remove the partial destination while SQLite still has it open. + _close_quietly(backup_conn) + backup_conn = None + with suppress(OSError): + dst.unlink(missing_ok=True) + return False + finally: + _close_quietly(backup_conn) + _close_quietly(conn) + + +def preflight_state_db(home: Path) -> dict: + """Publish an emergency snapshot; do not prune recovery files on failure.""" + source = home / "state.db" + if not source.exists(): + return {"path": None, "message": "state.db not found (fresh install?)"} + prefix = "state.db.pre-update-emergency-" + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%fZ") + destination = home / f"{prefix}{stamp}-{os.getpid()}.bak" + fd, name = tempfile.mkstemp(prefix=prefix, suffix=".partial", dir=home) + os.close(fd) + staged = Path(name) + try: + if not _safe_copy_db(source, staged): + raise RuntimeError("SQLite safe copy failed; previous emergency snapshots were retained") + connection = sqlite3.connect(str(staged)) + try: + result = connection.execute("PRAGMA quick_check").fetchall() + if result != [("ok",)]: + raise RuntimeError(f"SQLite snapshot integrity check failed: {result}") + finally: + connection.close() + size = staged.stat().st_size + os.replace(staged, destination) + finally: + staged.unlink(missing_ok=True) + for old in sorted(home.glob(f"{prefix}*.bak"), reverse=True)[2:]: + try: + old.unlink() + except OSError as exc: + logger.warning("Could not prune emergency snapshot %s: %s", old, exc) + return {"path": str(destination), "bytes": size} + + +if __name__ == "__main__": + print(json.dumps(preflight_state_db(Path(sys.argv[1])))) diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index fbd08a6977..b0751340b4 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1152,7 +1152,7 @@ class TestProfileRestoration: class TestSafeCopyDb: def test_copies_valid_database(self, tmp_path): - from hermes_cli.backup import _safe_copy_db + from hermes_cli.backup_sqlite import _safe_copy_db src = tmp_path / "test.db" dst = tmp_path / "copy.db" @@ -1175,7 +1175,7 @@ class TestSafeCopyDb: ): from types import SimpleNamespace - from hermes_cli import backup as backup_mod + from hermes_cli import backup_sqlite as backup_mod src = tmp_path / "locked.db" dst = tmp_path / "copy.db" @@ -1228,7 +1228,7 @@ class TestSafeCopyDb: import sys import time - from hermes_cli.backup import _safe_copy_db + from hermes_cli.backup_sqlite import _safe_copy_db src = tmp_path / "locked.db" dst = tmp_path / "copy.db" diff --git a/tests/hermes_cli/test_backup_preflight.py b/tests/hermes_cli/test_backup_preflight.py new file mode 100644 index 0000000000..e23e6ea997 --- /dev/null +++ b/tests/hermes_cli/test_backup_preflight.py @@ -0,0 +1,55 @@ +"""Preflight must work while the app is broken and its database is still live.""" +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +def test_preflight_captures_committed_wal_without_application_imports(tmp_path): + home = tmp_path / "home" + home.mkdir() + db = home / "state.db" + script = Path(__file__).resolve().parents[2] / "hermes_cli" / "backup_sqlite.py" + runner = """ +import runpy, sys +class NoApplicationImports: + def find_spec(self, fullname, path=None, target=None): + if fullname.startswith(('hermes', 'utils', 'yaml')): + raise ImportError('application imports are broken') +sys.meta_path.insert(0, NoApplicationImports()) +sys.argv = [sys.argv[1], sys.argv[2]] +runpy.run_path(sys.argv[0], run_name='__main__') +""" + with sqlite3.connect(db) as writer: + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("PRAGMA wal_autocheckpoint=0") + writer.execute("CREATE TABLE messages (body TEXT)") + writer.commit() + writer.execute("PRAGMA wal_checkpoint(TRUNCATE)") + writer.execute("INSERT INTO messages VALUES ('committed only in WAL')") + writer.commit() + assert Path(str(db) + "-wal").stat().st_size > 0 + # The control proves a main-file copy loses committed rows. + control = tmp_path / "raw.db" + control.write_bytes(db.read_bytes()) + with sqlite3.connect(control) as raw: + assert raw.execute("SELECT * FROM messages").fetchall() == [] + for _ in range(3): + result = subprocess.run( + [sys.executable, "-I", "-S", "-c", runner, str(script), str(home)], + capture_output=True, text=True, timeout=20, + ) + assert result.returncode == 0, result.stderr + backup = Path(json.loads(result.stdout)["path"]) + with sqlite3.connect(backup) as snapshot: + assert snapshot.execute("SELECT * FROM messages").fetchall() == [("committed only in WAL",)] + assert snapshot.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert len(list(home.glob("state.db.pre-update-emergency-*.bak"))) == 2 + writer.close() + previous = sorted(home.glob("state.db.pre-update-emergency-*.bak")) + db.write_bytes(b"broken database") + result = subprocess.run([sys.executable, "-I", "-S", str(script), str(home)], capture_output=True, text=True, timeout=20) + assert result.returncode != 0 + assert sorted(home.glob("state.db.pre-update-emergency-*.bak")) == previous + assert not list(home.glob("*.partial")) diff --git a/tests/hermes_cli/test_backup_zip_serialization.py b/tests/hermes_cli/test_backup_zip_serialization.py new file mode 100644 index 0000000000..9c945c1148 --- /dev/null +++ b/tests/hermes_cli/test_backup_zip_serialization.py @@ -0,0 +1,50 @@ +"""The manual and automatic archive paths use the same WAL-safe serialization.""" +import sqlite3 +import zipfile +from argparse import Namespace +from contextlib import closing +from pathlib import Path + +import pytest + +from hermes_cli import backup + + +@pytest.mark.parametrize("automatic", [False, True]) +def test_zip_captures_live_wal_and_cleans_failed_staging(tmp_path, monkeypatch, automatic): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + output = tmp_path / "archive" + output.mkdir() + archive = output / "backup.zip" + + def run(): + if automatic: + return backup._write_full_zip_backup(archive, home) + return backup.run_backup(Namespace(output=str(archive))) + + with closing(sqlite3.connect(home / "state.db")) as writer: + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("PRAGMA wal_autocheckpoint=0") + writer.execute("CREATE TABLE messages (body TEXT)") + writer.commit() + writer.execute("PRAGMA wal_checkpoint(TRUNCATE)") + writer.execute("INSERT INTO messages VALUES ('WAL-only row')") + writer.commit() + run() + with zipfile.ZipFile(archive) as zipped: + assert not any(name.endswith(('-wal', '-shm')) for name in zipped.namelist()) + member = next(name for name in zipped.namelist() if name.endswith('state.db')) + restored = tmp_path / "restored.db" + restored.write_bytes(zipped.read(member)) + with closing(sqlite3.connect(restored)) as snapshot: + assert snapshot.execute("SELECT body FROM messages").fetchall() == [("WAL-only row",)] + + def refuse_write(self, filename, arcname=None, **kwargs): + raise OSError("archive device full") + + monkeypatch.setattr(zipfile.ZipFile, "write", refuse_write) + run() + assert sorted(output.iterdir()) == [archive], "a failed write must not leak a private database snapshot" From b1cad3aa242ebe8a1de4ec07ef1404affed0bcc3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:04:13 -0400 Subject: [PATCH 14/33] fix(pm): preserve cross-target stages and recorded build inputs Cross-target Node verification attempts to execute foreign bytes before and after publication. Check the native target before smoke probes, while retaining file and architecture checks for every target. Repair must retain a plugin's build directory when it contains the declared PEP 517 backend. Use the same copy exclusions as the initial snapshot. The foreign-ELF execution trap and offline real-uv replay test fail before the fixes and pass after them. Native smoke probes and bionic no-execution checks also pass. The focused PM run reports nine unrelated failures, all reproduced at the starting commit. No full suite or native Windows validation was run. --- pm/packages.py | 9 ++- pm/workspace.py | 5 +- tests/pm/test_stage_only.py | 82 +++++++++++++++++++++++- tests/pm/test_workspace_build_inputs.py | 84 +++++++++++++++++++++++++ tests/test_pm_bionic.py | 15 +++-- 5 files changed, 181 insertions(+), 14 deletions(-) diff --git a/pm/packages.py b/pm/packages.py index 247308f323..04c444c745 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -19,7 +19,7 @@ from pm.package import ( _probe_reason, ) from pm.registry import register -from pm.store import ALL_TARGETS, Store, flatten_single_dir, merge_tree +from pm.store import ALL_TARGETS, Store, current_target, flatten_single_dir, merge_tree from pm.update import ( btbn_index, btbn_versions, @@ -80,16 +80,15 @@ class BinaryPackage(Package): return entry / rel if rel else None def verify(self, entry: Path, target: str) -> str: - """Return '' when the entry is usable on target, else why not: - a missing binary, a wrong-arch binary, or a --version probe that - fails to exec, times out, or exits nonzero.""" + """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: + if not self.probe_version or target != current_target(): return "" try: proc = subprocess.run( diff --git a/pm/workspace.py b/pm/workspace.py index 5d73d143b4..5a29aed04e 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -331,7 +331,7 @@ def lock_and_sync( """Prepare a fresh generation using explicit inputs and a prepared engine. The caller selects the seed; uv retains its compatible versions. Repair - copies the recorded workspace verbatim and never reads current manifests. + copies the recorded build inputs and never reads current manifests. Resolver conflicts remain distinct from download/build failures. """ if root.exists() or root.is_symlink(): @@ -345,7 +345,8 @@ def lock_and_sync( raise InstallError("venv", f"recorded workspace is missing: {replay}") # Sibling generations keep external relative paths at the same depth; # snapshotted members and their exact lock travel with the workspace. - shutil.copytree(replay, root, ignore=shutil.ignore_patterns("__pycache__", ".venv", "build", "*.egg-info")) + # Use the snapshot's exclusions: build/ may hold an in-tree backend. + shutil.copytree(replay, root, symlinks=True, ignore=_member_ignored) frozen = True environment.sync(root, extras=extras, frozen=frozen) diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index e8145bfc1d..4d27499c55 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -3,8 +3,8 @@ deleted (verify() returns '' on success), and stage_only must honor a same-version hash repin (the entry marker design, like facts' identity). Everything runs inside a temp HERMES_RUNTIME_DIR sandbox: the store and -facts live under tmp_path, and the lockfile + package registry are faked, -so no network and no real install state is touched. +facts live under tmp_path. Most tests use fake package definitions. The +Node tests use the real package with local archives, without network access. """ from __future__ import annotations @@ -86,6 +86,84 @@ TARGET = "linux-arm64-bionic" ENTRY = "stage-test-1.0-linux-arm64-bionic" +@pytest.mark.platforms("linux", arch="x86_64") +def test_real_node_foreign_stage_checks_bytes_without_exec(tmp_path, monkeypatch): + import io + import zipfile + + from pm import paths + from pm.package import machine_matches_binary + from pm.registry import get_package + from pm.store import current_target + + assert current_target() == "linux-x64" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime")) + store = Store(paths.store_root()) + # Real Node package/unpacker/verifier, with a hash-verified offline archive. + elf = bytearray(b"\x7fELF" + b"\0" * 60) + elf[4:7] = b"\x02\x01\x01" # ELF64, little endian, current ELF version + elf[18:20] = (0xB7).to_bytes(2, "little") # AArch64 + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as payload: + payload.writestr("node-v1.0-linux-arm64/bin/node", elf) + data = archive.getvalue() + _arm_lock(monkeypatch, [{"url": "https://example.test/node.zip", "sha256": _sha(data)}]) + cached = store.entry(f"fetch-{_sha(data)}") + cached.mkdir(parents=True) + (cached / "node.zip").write_bytes(data) + + def refuse_exec(*args, **kwargs): + pytest.fail(f"cross-target stage attempted execution: {args}") + + monkeypatch.setattr("pm.packages.subprocess.run", refuse_exec) + entry = ensure_mod.stage_only("node", "linux-arm64") + node = entry / "bin/node" + assert node.read_bytes() == elf + assert machine_matches_binary(node, "linux-arm64") is True + assert ensure_mod.stage_only("node", "linux-arm64") == entry + assert not paths.facts_path().exists() + assert not cached.exists() + + # The no-exec path must still diagnose wrong-architecture and missing bytes. + elf[18:20] = (0x3E).to_bytes(2, "little") # x86-64 + node.write_bytes(elf) + assert "not a linux-arm64 binary" in get_package("node").verify(entry, "linux-arm64") + node.unlink() + assert get_package("node").verify(entry, "linux-arm64") + + +@pytest.mark.platforms("posix") +def test_real_node_native_install_keeps_smoke_validation(tmp_path, sandbox, monkeypatch): + import io + import tarfile + + from pm.packages import Nodejs + from pm.store import current_target + + # An executable fixture makes native verification observable without a Node download. + probe = tmp_path / "native-probes" + script = f'#!/bin/sh\nprintf "%s\\n" "$1" >> "{probe}"\nexit 0\n'.encode() + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as payload: + member = tarfile.TarInfo("node-v1.0/bin/node") + member.mode = 0o755 + member.size = len(script) + payload.addfile(member, io.BytesIO(script)) + data = archive.getvalue() + _arm_lock(monkeypatch, [{"url": "https://example.test/node.tar.gz", "sha256": _sha(data)}]) + cached = sandbox.entry(f"fetch-{_sha(data)}") + cached.mkdir(parents=True) + (cached / "node.tar.gz").write_bytes(data) + package, facts = Nodejs(), ensure_mod._facts() + entry = ensure_mod._install(package, ensure_mod._lockfile(), facts, sandbox, current_target()) + assert probe.read_text().splitlines() == ["--version", "--version"] + assert facts.get("node")["entry"] == entry.name + (entry / "bin/node").write_text("#!/bin/sh\nexit 23\n") + assert "23" in package.verify(entry, current_target()) + + def test_stage_only_keeps_valid_entry(tmp_path, sandbox, monkeypatch): """A published entry that verifies must be returned as-is: the verify contract is '' on success, so an inverted predicate here would diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index 99071a176c..d2b094f630 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -49,6 +49,90 @@ def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch): assert not (core / "uv.lock").exists() +def test_repair_replays_saved_in_tree_build_backend(tmp_path): + import os + import tomllib + + from pm.environment import PythonEnvironment + + core, plugin = tmp_path / "core", tmp_path / "plugin" + core.mkdir() + (plugin / "build").mkdir(parents=True) + (core / "pyproject.toml").write_text( + '[project]\nname="replay-core"\nversion="1"\nrequires-python=">=3.11"\n' + '[tool.uv]\npackage=false\nno-index=true\n', encoding="utf-8", + ) + (plugin / "pyproject.toml").write_text( + '[project]\nname="replay-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n' + '[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["build"]\n', + encoding="utf-8", + ) + (plugin / "plugin.yaml").write_text("name: replay-plugin\n", encoding="utf-8") + (plugin / "replay_plugin.py").write_text("VALUE = 'recorded plugin bytes'\n", encoding="utf-8") + # A real, dependency-free PEP 517/660 backend. Its directory is source, not output. + (plugin / "build/backend.py").write_text(''' +from pathlib import Path +from zipfile import ZipFile + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + name = "replay_plugin-1.0-py3-none-any.whl" + dist = "replay_plugin-1.0.dist-info" + entries = { + "replay_plugin.py": Path("replay_plugin.py").read_bytes(), + dist + "/METADATA": "Metadata-Version: 2.1\\nName: replay-plugin\\nVersion: 1.0\\n", + dist + "/WHEEL": "Wheel-Version: 1.0\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n", + } + entries[dist + "/RECORD"] = "".join(path + ",,\\n" for path in entries) + with ZipFile(Path(wheel_directory) / name, "w") as wheel: + for path, body in entries.items(): + wheel.writestr(path, body) + return name + +build_editable = build_wheel +''', encoding="utf-8") + inputs = {p.relative_to(plugin): p.read_bytes() for p in plugin.rglob("*") if p.is_file()} + uv = shutil.which("uv") + assert uv, "saved backend replay test requires real uv" + saved, repaired = tmp_path / "saved", tmp_path / "repaired" + initial = PythonEnvironment( + uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "initial-env", + cache=tmp_path / "initial-cache", env=dict(os.environ), offline=True, + ) + workspace.lock_and_sync([plugin], [], root=saved, source=core, seed_lock=None, + environment=initial) + [relative] = tomllib.loads((saved / "pyproject.toml").read_text())["tool"]["uv"]["workspace"]["members"] + assert all((saved / relative / path).read_bytes() == data for path, data in inputs.items()) + saved_lock = (saved / "uv.lock").read_bytes() + + # Neither live manifests nor the live backend can provide repair's build inputs. + (core / "pyproject.toml").write_text("damaged [", encoding="utf-8") + (plugin / "pyproject.toml").write_text("damaged [", encoding="utf-8") + (plugin / "plugin.yaml").write_text("damaged [", encoding="utf-8") + (plugin / "build/backend.py").unlink() + (plugin / "replay_plugin.py").write_text("raise RuntimeError('damaged live source')\n", encoding="utf-8") + repair = PythonEnvironment( + uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "repair-env", + # A fresh cache forces uv to invoke the saved backend again, not reuse a wheel. + cache=tmp_path / "repair-cache", env=dict(os.environ), offline=True, + ) + workspace.lock_and_sync([plugin], [], root=repaired, source=core, seed_lock=None, + replay=saved, environment=repair) + assert (repaired / "uv.lock").read_bytes() == saved_lock + assert (saved / "uv.lock").read_bytes() == saved_lock + for root in (saved, repaired): + assert all((root / relative / path).read_bytes() == data for path, data in inputs.items()) + for environment in (initial, repair): + probe = subprocess.run( + [str(environment.executable), "-I", "-c", "import replay_plugin; print(replay_plugin.VALUE)"], + cwd=tmp_path, text=True, capture_output=True, check=True, timeout=30, + ) + assert probe.stdout.strip() == "recorded plugin bytes" + assert (core / "pyproject.toml").read_text() == "damaged [" + assert (plugin / "pyproject.toml").read_text() == "damaged [" + assert (plugin / "plugin.yaml").read_text() == "damaged [" + assert not (plugin / "build/backend.py").exists() + + def test_source_refresh_does_not_need_metadata_change_and_refuses_live_root(tmp_path, monkeypatch): core = tmp_path / "core" core.mkdir() diff --git a/tests/test_pm_bionic.py b/tests/test_pm_bionic.py index 444d15a63b..85599a36dd 100644 --- a/tests/test_pm_bionic.py +++ b/tests/test_pm_bionic.py @@ -152,20 +152,25 @@ def test_debpackage_unpack_hardened(tmp_path: Path): _P().unpack(evil, tmp_path / "staged2", "linux-arm64-bionic") -def test_python_bionic_verify_is_file_evidence(tmp_path: Path): +@pytest.mark.parametrize("name", ["python", "uv", "node"]) +def test_bionic_verify_is_file_evidence(tmp_path: Path, monkeypatch, name): """bionic verify never executes the staged binary; presence is the contract (the digest already proved the bytes).""" from pm.registry import get_package - py = get_package("python") - bin_rel = Path(py.prefix_rel) / py.main_rel("linux-arm64-bionic") + def refuse_exec(*args, **kwargs): + pytest.fail(f"bionic verification attempted execution: {args}") + + monkeypatch.setattr("pm.packages.subprocess.run", refuse_exec) + package = get_package(name) + bin_rel = Path(package.prefix_rel) / package.main_rel("linux-arm64-bionic") entry = tmp_path / "entry" (entry / bin_rel).parent.mkdir(parents=True) (entry / bin_rel).write_bytes(b"bionic-elf-bytes") - assert py.verify(entry, "linux-arm64-bionic") == "" + assert package.verify(entry, "linux-arm64-bionic") == "" empty = tmp_path / "empty" empty.mkdir() - assert "missing" in py.verify(empty, "linux-arm64-bionic") + assert "missing" in package.verify(empty, "linux-arm64-bionic") def test_bionic_binary_and_env_contract(tmp_path: Path): From 5cae2e679b5a419d85a10320eb2ea6a1d41581f7 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:06:05 -0400 Subject: [PATCH 15/33] Document explicit generation inputs and recorded repair replay --- website/docs/reference/package-management.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 0117bac1cc..5746706d00 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -109,7 +109,10 @@ legacy `pip_dependencies` or `python_dependencies` lists in `plugin.yaml`. PM prepares core requirements, enabled extras, and enabled plugin requirements together. It seeds resolution from the existing lock. Compatible transitive versions can change, but declared constraints and exact pins remain binding. -The generated workspace and extended lock remain outside shipped source. +Each candidate gets a fresh workspace with explicit source, lock seed, and +prepared environment inputs. The generated workspace and extended lock remain +outside shipped source. Repair copies the recorded workspace and lock, including +plugin build inputs, rather than resolving against edited live manifests. A failed candidate does not replace the selected environment or silently disable other plugins. If preparation succeeds, a restart can still be required From d190198c770a459635284085ef31a32037adbe4d Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:05:25 -0400 Subject: [PATCH 16/33] Make PM workers own plugin publication and recovery Replace member and publication callbacks with concrete selection/staged-tree requests. Discover under the install lock, validate staged identities, preserve YAML 1.1 semantics, reject stale config and concurrent inputs, and journal code/config/metadata before selecting facts. Keep boot recovery stdlib-only and preserve inactive/code-only publication. Fix missing-but-enabled target discovery and propagate explicit install intent into Venv tool acquisition. Migrate memory-provider candidates to concrete additional directory inputs. Preserve progress/cancellation and existing updater compatibility surfaces. Verification: focused baseline 64 passing tests; final 32-file run 282 passed, 0 failed. Real PM-only workers, local wheels/projects, competing edits, exact rollback, and 16 process-death publication boundary cases exercised. --- hermes_cli/memory_setup.py | 3 +- hermes_cli/plugins_admission.py | 92 +---- hermes_cli/plugins_cmd.py | 47 ++- hermes_cli/plugins_transaction.py | 119 +----- hermes_cli/runtime_state.py | 63 +-- pm/client.py | 40 +- pm/ensure.py | 72 ++-- pm/package.py | 2 +- pm/packages.py | 4 +- pm/plugins_state.py | 9 +- pm/publication.py | 170 ++++++++ pm/worker.py | 23 +- pm/workspace.py | 11 +- .../hermes_cli/test_plugin_update_recovery.py | 7 +- .../test_plugins_admission_setter.py | 41 +- tests/hermes_cli/test_plugins_update_sync.py | 4 +- .../test_tools_config_post_setup.py | 10 +- tests/pm/test_admission_members_locked.py | 4 +- tests/pm/test_pm_core.py | 2 +- tests/pm/test_runtime_context_home.py | 4 +- .../test_runtime_journal_concurrent_edit.py | 4 +- tests/pm/test_runtime_public.py | 2 +- tests/pm/test_runtime_recovery.py | 4 +- tests/pm/test_runtime_transaction.py | 2 +- tests/pm/test_worker.py | 138 +++---- tests/pm/test_worker_publication.py | 386 ++++++++++++++++++ tests/pm/test_workspace.py | 4 +- website/docs/reference/package-management.md | 7 + 28 files changed, 828 insertions(+), 446 deletions(-) create mode 100644 pm/publication.py create mode 100644 tests/pm/test_worker_publication.py diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index c5daee0ffb..bac6a6cd31 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -53,7 +53,6 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str def memory_provider_dependency_inputs(provider_name: str) -> tuple[dict, dict]: """Read one candidate declaration for preparation and passive readiness.""" - from hermes_cli.plugins_admission import candidate_member_dirs from hermes_cli.plugins_cmd import PluginOperationError, _read_manifest_for_install from pm.package import InstallError from pm.workspace import _is_member_candidate @@ -74,7 +73,7 @@ def memory_provider_dependency_inputs(provider_name: str) -> tuple[dict, dict]: return meta, {} # Without a proposed home, selection retains every configured member. - inputs = {"plugin_dirs": lambda: candidate_member_dirs((), extra_dirs=[plugin_dir])} if member else {} + inputs = {"extra_plugin_dirs": [plugin_dir]} if member else {} return meta, {"extras": extras, **inputs} diff --git a/hermes_cli/plugins_admission.py b/hermes_cli/plugins_admission.py index 7cdd20484b..38f296ef13 100644 --- a/hermes_cli/plugins_admission.py +++ b/hermes_cli/plugins_admission.py @@ -1,5 +1,4 @@ -"""Validate a proposed plugin set, then publish config and runtime selection.""" - +"""Submit proposed plugin selections to the independent PM publisher.""" from __future__ import annotations from pathlib import Path @@ -10,96 +9,27 @@ class AdmissionRefused(RuntimeError): """The candidate set was refused; config and environment untouched.""" -def candidate_member_dirs( - candidate_enabled: Iterable[str], - candidate_disabled: Iterable[str] = (), - *, - active_plugins_dir: Optional[Path] = None, - extra_dirs: Iterable[Path] = (), -) -> list[Path]: - """Member dirs implied by the PROPOSED enabled sets: per plugins dir, - its enabled names — the active dir's replaced by the candidate set - (removals excluded), other homes unchanged — filtered to dirs that - actually declare python deps. ``extra_dirs`` (the install target) - join when they declare deps.""" - from pm.workspace import enabled_member_dirs, _is_member_candidate - - active = Path(active_plugins_dir) if active_plugins_dir else None - members = enabled_member_dirs( - proposed_home=active.parent if active else None, - enabled=candidate_enabled, disabled=candidate_disabled, - ) - for directory in extra_dirs: - directory = Path(directory) - if directory not in members and _is_member_candidate(directory): - members.append(directory) - return members - - -def _config_path() -> Path: - from hermes_cli.config import get_hermes_home - - return get_hermes_home() / "config.yaml" - - -def _config_commit(candidate_enabled: set, candidate_disabled: set): - """Journal both config versions before publishing either config or facts.""" - import shutil - import tempfile - - from pm.paths import repo_root - from hermes_cli.runtime_state import begin_publication, _atomic_bytes - from hermes_cli.config import read_raw_config - from utils import atomic_roundtrip_yaml_save - - path = _config_path() - config = read_raw_config() - plugins_cfg = config.setdefault("plugins", {}) - if not isinstance(plugins_cfg, dict): - raise ValueError(f"plugins must be a mapping in {path}") - plugins_cfg["enabled"] = sorted(candidate_enabled) - plugins_cfg["disabled"] = sorted(candidate_disabled) - with tempfile.TemporaryDirectory(prefix="hermes-admission-") as temporary: - staged = Path(temporary) / "config.yaml" - if path.is_file(): - shutil.copyfile(path, staged) - atomic_roundtrip_yaml_save(staged, config) - proposed = staged.read_bytes() - publication = begin_publication(repo_root(), path, proposed) - try: - _atomic_bytes(path, proposed) - except BaseException: - publication() - raise - return publication - - def admit_plugin_set_change( candidate_enabled: set, candidate_disabled: set, *, active_plugins_dir: Optional[Path] = None, extra_dirs: Iterable[Path] = (), + expected_config: str | None = None, ) -> None: - """Validate the proposed sets against the active environment and - commit — env selection and config move together, inside pm's single - locked transaction. + """PM discovers and validates the proposed union under its install lock. - Raises :class:`AdmissionRefused` BEFORE anything is published when - the candidate union fails to resolve (conflict, frozen feature set, - …) or when the config write itself fails. The active environment and - the previous config bytes are kept EXACTLY — no rollback re-resolve. + No config or dependency selection is written by this application process. """ + from hermes_constants import get_hermes_home from pm.client import sync_venv - extra_dirs = tuple(extra_dirs) + home = Path(active_plugins_dir).parent if active_plugins_dir is not None else get_hermes_home() try: - sync_venv( - explicit=True, - plugin_dirs=lambda: candidate_member_dirs( - candidate_enabled, candidate_disabled, active_plugins_dir=active_plugins_dir, extra_dirs=extra_dirs - ), - before_publish=lambda: _config_commit(candidate_enabled, candidate_disabled), - ) + sync_venv(explicit=True, selection={ + "home": str(home.resolve()), "enabled": sorted(candidate_enabled), + "disabled": sorted(candidate_disabled), "extra_dirs": [str(Path(d).resolve()) for d in extra_dirs], + **({"expected_config": expected_config} if expected_config is not None else {}), + }) except Exception as exc: raise AdmissionRefused(str(exc)) from exc diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 2acd905aa5..2aaa9031be 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -852,6 +852,7 @@ def cmd_install( if should_enable: from hermes_cli.plugins_admission import AdmissionRefused + expected_config = _plugin_selection_version() enabled = _get_enabled_set() disabled = _get_disabled_set() enabled.add(installed_name) @@ -862,7 +863,7 @@ def cmd_install( disabled, extra_dirs=[target], console=console, - action=f"Enable '{installed_name}'", + action=f"Enable '{installed_name}'", expected_config=expected_config, ) except AdmissionRefused: console.print( @@ -1012,19 +1013,23 @@ def _save_enabled_set(enabled: set) -> None: _write_config_value("plugins", "enabled", sorted(enabled)) -def _save_plugin_sets(enabled: set, disabled: set) -> None: +def _plugin_selection_version() -> str: + from hermes_cli.runtime_state import _digest + return _digest(get_hermes_home() / "config.yaml") or "missing" + + +def _save_plugin_sets(enabled: set, disabled: set, *, expected_config: str | None = None) -> None: from hermes_cli.plugins_admission import admit_plugin_set_change - admit_plugin_set_change(enabled, disabled, active_plugins_dir=_plugins_dir()) + admit_plugin_set_change(enabled, disabled, active_plugins_dir=_plugins_dir(), expected_config=expected_config) def _admit_and_save_plugin_sets( - enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable" + enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None ) -> None: """ONE admission authority for proposed enabled/disabled sets (C13): the candidate union is resolved against the ACTIVE environment and - the config commits inside the same locked pm transaction (sync's - ``before_publish`` hook) — a refusal or a config-write failure + the config commits inside the same worker-owned PM transaction — a refusal or a config-write failure publishes nothing: previous config bytes AND previous environment stay exactly in place. Raises :class:`AdmissionRefused` (UI callers catch and surface it — admission never auto-disables to fit).""" @@ -1032,7 +1037,7 @@ def _admit_and_save_plugin_sets( try: admit_plugin_set_change( - enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs + enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config ) except AdmissionRefused as exc: if console is not None: @@ -1071,11 +1076,12 @@ def _discard_key_and_leaf(names: set, key: str) -> None: def _set_plugin_enabled(name: str, *, enable: bool) -> None: """Persist the proposed selection through the same dependency transaction.""" + expected_config = _plugin_selection_version() enabled = _get_enabled_set() disabled = _get_disabled_set() (enabled.add if enable else enabled.discard)(name) (disabled.discard if enable else disabled.add)(name) - _save_plugin_sets(enabled, disabled) + _save_plugin_sets(enabled, disabled, expected_config=expected_config) def _resolve_plugin_key(name: str) -> Optional[str]: @@ -1134,6 +1140,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: key, source = resolved _refuse_legacy_relay(key) + expected_config = _plugin_selection_version() enabled = _get_enabled_set() disabled = _get_disabled_set() if key in enabled and key not in disabled: @@ -1147,7 +1154,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: manifest_name = next((e[0] for e in _discover_all_plugins() if e[5] == key), None) if manifest_name is not None: disabled.discard(manifest_name) - _admit_and_save_plugin_sets(enabled, disabled, console=console, action=f"Enable '{key}'") + _admit_and_save_plugin_sets(enabled, disabled, console=console, action=f"Enable '{key}'", expected_config=expected_config) console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.") # Built-in tool override is a privileged grant; bundled plugins are trusted. @@ -1313,6 +1320,7 @@ def cmd_disable(name: str) -> None: key = _resolve_plugin_key(name) if key is None: _fail(console, f"[red]Plugin '{name}' is not installed or bundled.[/red]") + expected_config = _plugin_selection_version() enabled = _get_enabled_set() disabled = _get_disabled_set() if key not in enabled and key in disabled: @@ -1321,7 +1329,7 @@ def cmd_disable(name: str) -> None: # Also drop a stale legacy bare-name entry so it can't keep a nested plugin loading. _discard_key_and_leaf(enabled, key) disabled.add(key) - _save_plugin_sets(enabled, disabled) + _save_plugin_sets(enabled, disabled, expected_config=expected_config) console.print( f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. Takes effect on next session.") @@ -1759,6 +1767,7 @@ def cmd_toggle() -> None: """Interactive composite UI — general plugins + provider plugin categories.""" console = _console() entries = _discover_all_plugins() + expected_config = _plugin_selection_version() enabled_set = _get_enabled_set() disabled_set = _get_disabled_set() @@ -1783,12 +1792,12 @@ def cmd_toggle() -> None: return try: import curses - _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console) + _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) except ImportError: - _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console) + _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) -def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set]: +def _persist_plugin_selection(plugin_keys, chosen, disabled, *, expected_config=None) -> tuple[bool, set]: """Save the composite UI's checkbox state; returns ``(changed, new_enabled)``. Unchecked plugins go to the disabled-list (so they stay off even if something auto-enables @@ -1798,6 +1807,8 @@ def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set] # See #40190. # Persist by canonical key only — never the bare manifest name — so the disabled-list stays aligned with # cmd_enable / PluginManager (#40190). + if expected_config is None: + expected_config = _plugin_selection_version() new_enabled: set = set() new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins for i, key in enumerate(plugin_keys): @@ -1812,11 +1823,11 @@ def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set] # C13: the composite UI's candidate goes through the ONE admission # authority — refusal raises AdmissionRefused BEFORE any config # write; the caller surfaces it and the selection stays unsaved. - _admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection") + _admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection", expected_config=expected_config) return changed, new_enabled -def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console): +def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): """Custom curses screen with checkboxes + category action rows.""" from hermes_cli.curses_ui import _addnstr, flush_stdin chosen = set(plugin_selected) @@ -1929,7 +1940,7 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab from hermes_cli.plugins_admission import AdmissionRefused try: - changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled) + changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) except AdmissionRefused as exc: console.print(f"[red]✗[/red] Plugin selection refused, not saved: {exc}") console.print( @@ -1952,7 +1963,7 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab console.print() -def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled, categories, console): +def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): """Text-based fallback for the composite plugins UI.""" from hermes_cli.colors import Colors, color print(color("\n Plugins", Colors.YELLOW)) @@ -1998,7 +2009,7 @@ def _save_plugin_selection_fallback(plugin_keys, chosen, disabled) -> None: from hermes_cli.plugins_admission import AdmissionRefused try: - _persist_plugin_selection(plugin_keys, chosen, disabled) + _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) except AdmissionRefused as exc: print(f" Plugin selection refused, not saved: {exc}") print(" config.yaml and the active environment are unchanged.") diff --git a/hermes_cli/plugins_transaction.py b/hermes_cli/plugins_transaction.py index 7a70ddac16..a45aea3ddb 100644 --- a/hermes_cli/plugins_transaction.py +++ b/hermes_cli/plugins_transaction.py @@ -1,121 +1,20 @@ """Publish plugin code and its dependency selection through one recoverable handoff.""" from __future__ import annotations -import base64 -import json -import os from pathlib import Path import shutil -import uuid -def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None: - from hermes_cli.fs_utils import rmtree_force - from hermes_cli.runtime_paths import dependency_home_root, runtime_facts_path - from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest - - target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata")) - home = dependency_home_root().resolve() - if (not target.resolve().is_relative_to(home) or target.parent.name != "plugins" - or backup.parent != target.parent or not backup.name.startswith(".previous-") - or metadata != target.parent / ".install-metadata.json"): - raise ValueError("plugin publication paths escape their home") - committed = row.get("committed") or _digest(runtime_facts_path(project)) != row["facts_before"] - if committed: - if backup.exists(): - rmtree_force(backup) - else: - old = base64.b64decode(row["metadata_before"], validate=True) if row["metadata_before"] is not None else None - current = _bytes(metadata) - new = base64.b64decode(row["metadata_after"], validate=True) - if current not in (old, new): - raise ValueError("plugin metadata changed after publication; preserve it for manual recovery") - if backup.exists(): - if target.exists(): - rmtree_force(target) - os.replace(backup, target) - elif not row["target_existed"] and target.exists(): - rmtree_force(target) - if old is None: - metadata.unlink(missing_ok=True) - else: - _atomic_bytes(metadata, old) - journal.unlink() - - -class PluginPublication: - def __init__(self, project: Path, staged: Path, target: Path, metadata: dict): - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path - from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest - - self.project = project - self.journal = install_state_dir(project) / "publication.json" - backup = target.parent / f".previous-{uuid.uuid4().hex}" - metadata_path = target.parent / ".install-metadata.json" - previous = _bytes(metadata_path) - proposed = (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8") - self.row = { - "kind": "plugin", "target": str(target), "backup": str(backup), "metadata": str(metadata_path), - "target_existed": target.exists(), "facts_before": _digest(runtime_facts_path(project)), - "metadata_before": base64.b64encode(previous).decode() if previous is not None else None, - "metadata_after": base64.b64encode(proposed).decode(), - } - _atomic_bytes(self.journal, json.dumps(self.row).encode()) - try: - if target.exists(): - os.replace(target, backup) - os.replace(staged, target) - _atomic_bytes(metadata_path, proposed) - except BaseException: - self() - raise - - def __call__(self) -> None: - recover_plugin_publication(self.project, self.row, self.journal) - - def finish(self) -> None: - from hermes_cli.runtime_state import _atomic_bytes - - # A code-only update has no new environment fact to mark its commit. - self.row["committed"] = True - _atomic_bytes(self.journal, json.dumps(self.row).encode()) - recover_plugin_publication(self.project, self.row, self.journal) - - -def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict) -> None: - from hermes_cli import plugins_cmd - from hermes_cli.runtime_state import recover_publication, runtime_lock - from pm import paths +def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict, + *, target_digest: str | None = None) -> None: from pm.client import sync_venv - from pm.workspace import _is_member_candidate, enabled_plugin_dirs, member_sources + from pm.store import tree_digest - project = paths.repo_root() - - def candidate_members(): - if plugins_cmd._read_install_metadata() != old_metadata: - raise plugins_cmd.PluginOperationError("Plugin install metadata changed while preparing the update; retry.") - sources = member_sources(enabled_plugin_dirs()) - active = target.resolve() in sources - if active: - sources[target.resolve()] = staged - for source in sources.values(): - plugins_cmd._check_manifest_version(plugins_cmd._read_manifest_for_install(source), source.name) - return {identity: source for identity, source in sources.items() if _is_member_candidate(source)} - - # Validate all active dependencies. The installed identity remains stable while - # PM snapshots the staged inputs into the candidate generation's workspace. - active = target.resolve() in member_sources(enabled_plugin_dirs()) - if active: - sync_venv(explicit=True, plugin_dirs=candidate_members, - before_publish=lambda: PluginPublication(project, staged, target, new_metadata)) - else: - with runtime_lock(project): - recover_publication(project) - if target.resolve() in member_sources(enabled_plugin_dirs()): - raise plugins_cmd.PluginOperationError("Plugin enablement changed while preparing the install; retry.") - if plugins_cmd._read_install_metadata() != old_metadata: - raise plugins_cmd.PluginOperationError("Plugin install metadata changed while preparing the install; retry.") - PluginPublication(project, staged, target, new_metadata).finish() + sync_venv(explicit=True, staged_plugin={ + "staged": str(staged.resolve()), "target": str(target.absolute()), + "old_metadata": old_metadata, "new_metadata": new_metadata, + "target_digest": target_digest if target_digest is not None else (tree_digest(target) if target.exists() else None), + }) def update_plugin(target: Path, *, catalog_entry=None) -> str: @@ -196,7 +95,7 @@ def update_plugin(target: Path, *, catalog_entry=None) -> str: record["revision"] = revision if tree_digest(staged) == before: return output - publish_plugin(staged, target, metadata, {**metadata, target.name: record}) + publish_plugin(staged, target, metadata, {**metadata, target.name: record}, target_digest=before) return output except pc.PluginOperationError: raise diff --git a/hermes_cli/runtime_state.py b/hermes_cli/runtime_state.py index 42a17a0c9f..faf5fd2ca7 100644 --- a/hermes_cli/runtime_state.py +++ b/hermes_cli/runtime_state.py @@ -83,6 +83,38 @@ def _atomic_bytes(path: Path, data: bytes): Path(temporary).unlink(missing_ok=True) +def _recover_plugin_publication(project: Path, row: dict, journal: Path) -> None: + from hermes_cli.fs_utils import rmtree_force + + target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata")) + home = dependency_home_root().resolve() + if (not target.resolve().is_relative_to(home) or target.parent.name != "plugins" + or backup.parent != target.parent or not backup.name.startswith(".previous-") + or metadata != target.parent / ".install-metadata.json"): + raise ValueError("plugin publication paths escape their home") + committed = row.get("committed") or _digest(runtime_facts_path(project)) != row["facts_before"] + if committed: + if backup.exists(): + rmtree_force(backup) + else: + old = base64.b64decode(row["metadata_before"], validate=True) if row["metadata_before"] is not None else None + current = _bytes(metadata) + new = base64.b64decode(row["metadata_after"], validate=True) + if current not in (old, new): + raise ValueError("plugin metadata changed after publication; preserve it for manual recovery") + if backup.exists(): + if target.exists(): + rmtree_force(target) + os.replace(backup, target) + elif not row["target_existed"] and target.exists(): + rmtree_force(target) + if old is None: + metadata.unlink(missing_ok=True) + else: + _atomic_bytes(metadata, old) + journal.unlink() + + def recover_publication(project: Path) -> None: """Recover while holding runtime_lock, before activation or another write.""" journal = install_state_dir(project) / "publication.json" @@ -92,15 +124,13 @@ def recover_publication(project: Path) -> None: try: row = json.loads(data) if row.get("kind") == "plugin": - from hermes_cli.plugins_transaction import recover_plugin_publication - - recover_plugin_publication(project, row, journal) + _recover_plugin_publication(project, row, journal) return config = Path(row["config"]) if config.name != "config.yaml" or not config.resolve().is_relative_to(dependency_home_root().resolve()): raise ValueError("config path is outside Hermes state") previous = base64.b64decode(row["previous"], validate=True) if row["previous"] is not None else None - if _digest(runtime_facts_path(project)) == row["facts_before"]: + if not row.get("committed") and _digest(runtime_facts_path(project)) == row["facts_before"]: current = _digest(config) prior = hashlib.sha256(previous).hexdigest() if previous is not None else None if current not in (prior, row.get("config_after")): @@ -115,26 +145,13 @@ def recover_publication(project: Path) -> None: raise RuntimeError(f"cannot recover dependency publication: {journal}: {exc}") from exc -class Publication: - def __init__(self, project: Path, config: Path, proposed: bytes | None = None): - self.project = project - self.journal = install_state_dir(project) / "publication.json" - previous = _bytes(config) - row = {"config": str(config.resolve()), "previous": base64.b64encode(previous).decode() if previous is not None else None, - "facts_before": _digest(runtime_facts_path(project)), - "config_after": hashlib.sha256(proposed).hexdigest() if proposed is not None else None} - _atomic_bytes(self.journal, json.dumps(row).encode()) - - def __call__(self) -> None: - recover_publication(self.project) - - def finish(self) -> None: - self.journal.unlink(missing_ok=True) - - -def begin_publication(project: Path, config: Path, proposed: bytes | None = None) -> Publication: +def finish_publication(project: Path) -> None: + """Persist a commit even when code/config changed without a new generation.""" + journal = install_state_dir(project) / "publication.json" + row = json.loads(journal.read_bytes()) + row["committed"] = True + _atomic_bytes(journal, json.dumps(row).encode()) recover_publication(project) - return Publication(project, config, proposed) def lease_generation(environment: Path) -> None: diff --git a/pm/client.py b/pm/client.py index 95b6da96e4..9359de642a 100644 --- a/pm/client.py +++ b/pm/client.py @@ -122,10 +122,8 @@ def _request(operation, arguments, *, callbacks=None, pause_event=None, project_ break name = response["callback"] try: - value = callbacks[name](*response.get("args", [])) - if name not in ("plugin_dirs", "before_publish"): - value = None - send({"type": "callback_result", "call": response["call"], "result": value}) + callbacks[name](*response.get("args", [])) + send({"type": "callback_result", "call": response["call"], "result": None}) except BaseException as exc: if callback_error is None: callback_error = exc @@ -187,30 +185,19 @@ def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=No return Runner(name, env_for(name, base_env=base_env)) -def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None, repair=False, +def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, extra_plugin_dirs=(), selection=None, staged_plugin=None, repair=False, project_root: Path | None = None) -> None: + if selection is not None and "expected_config" not in selection: + from hermes_cli.runtime_state import _digest + selection = {**selection, "expected_config": _digest(Path(selection["home"]) / "config.yaml") or "missing"} foreign = project_root is not None and Path(project_root).resolve() != paths.repo_root().resolve() if is_runtime() and not foreign: from pm.ensure import sync_venv as direct return direct(extras, explicit=explicit, plugin_dirs=plugin_dirs, - before_publish=before_publish, repair=repair) - callbacks = {} - if callable(plugin_dirs): - callbacks["plugin_dirs"] = lambda: _members(plugin_dirs()) - members = None - else: - members = _members(plugin_dirs) - if before_publish is not None: - def publish(): - publication = before_publish() - if publication is not None: - callbacks["undo"] = publication - if hasattr(publication, "finish"): - callbacks["finish"] = publication.finish - return {"undo": publication is not None, "finish": hasattr(publication, "finish")} - callbacks["before_publish"] = publish + selection=selection, staged_plugin=staged_plugin, extra_plugin_dirs=extra_plugin_dirs, repair=repair) _request("sync_venv", {"extras": extras, "explicit": explicit, "repair": repair, - "plugin_dirs": members}, callbacks=callbacks, project_root=project_root) + "plugin_dirs": _members(plugin_dirs), "selection": selection, "staged_plugin": staged_plugin, + "extra_plugin_dirs": [str(Path(p).absolute()) for p in extra_plugin_dirs]}, project_root=project_root) def stage_only(name, target, *, progress=None) -> Path: @@ -302,15 +289,16 @@ def ensure_python_tool( })) -def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, +def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, extra_plugin_dirs=(), project_root: Path | None = None) -> bool: """Check through a ready PM, never bootstrap dependencies for a probe.""" if is_runtime() and (project_root is None or Path(project_root).resolve() == paths.repo_root().resolve()): from pm.ensure import venv_is_current as direct - return direct(extras=extras, plugin_dirs=plugin_dirs, project_root=project_root) - members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs + return direct(extras=extras, plugin_dirs=plugin_dirs, extra_plugin_dirs=extra_plugin_dirs, project_root=project_root) + members = plugin_dirs try: - return bool(_request("venv_is_current", {"extras": extras, "plugin_dirs": _members(members)}, + return bool(_request("venv_is_current", {"extras": extras, "plugin_dirs": _members(members), + "extra_plugin_dirs": [str(Path(p).absolute()) for p in extra_plugin_dirs]}, project_root=project_root)) except InstallError as exc: if exc.package == "pm-runtime": diff --git a/pm/ensure.py b/pm/ensure.py index c84b9daf13..0166122fd9 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -444,7 +444,7 @@ def _runtime_state_matches(fact: dict, stamp: str, *, project_root: Path | None return (environment / "pyvenv.cfg").is_file() -def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, +def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, extra_plugin_dirs=(), project_root: Path | None = None) -> bool: """Probe the requested union without changing recorded dependency state.""" from hermes_cli.runtime_paths import runtime_facts_path @@ -462,13 +462,16 @@ def venv_is_current(*, extras: list[str] | None = None, plugin_dirs=None, or any(not isinstance(extra, str) for extra in fact["extras"])): raise ValueError("invalid recorded dependency state") enabled = sorted(set(fact["extras"]) | set(extras or [])) - members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs + from pm.publication import candidate_members + if extra_plugin_dirs and plugin_dirs is not None: + raise ValueError("additional candidates require member discovery") + members = candidate_members(extra_plugin_dirs) if extra_plugin_dirs else plugin_dirs inputs = {} if members is None else {"plugin_dirs": members} stamp = package.expected_stamp(enabled, **inputs) return _runtime_state_matches(fact, stamp, project_root=root) -def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plugin_dirs=None, before_publish=None, repair: bool = False) -> None: +def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plugin_dirs=None, extra_plugin_dirs=(), selection=None, staged_plugin=None, repair: bool = False) -> None: """Make the venv match uv.lock + the enabled extras. Extras union into the installed state (one ledger); no-op when the stamp already matches. ``repair`` restores the recorded dependency graph into a fresh generation, @@ -487,19 +490,15 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu BEFORE the frozen/lazy refusals (a refusal is a recorded ``failed`` outcome, not a silent raise); finalize runs in FINALLY — no-op syncs ("ok" with ``venv_rebuild`` false) and refusals ("failed") both get - a receipt. ``before_publish`` is the concrete selection hook: called - while the install lock is held, AFTER the environment staged and - BEFORE the facts write — it returns an undo callable that runs if - the facts write then fails, so a selection committed here is rolled - back atomically instead of drifting from the surviving environment. - No module globals, no callback framework — one hook, one consumer.""" + a receipt. Plugin selection data is discovered and published by the worker + under this lock; no executable transaction phases cross the process boundary.""" from pm import receipt from pm.features import read_features token = receipt.begin("sync") outcome = "failed" try: - if repair and (extras is not None or plugin_dirs is not None or before_publish is not None): + if repair and (extras is not None or plugin_dirs is not None or selection is not None or staged_plugin is not None or extra_plugin_dirs): raise ValueError("repair restores the recorded environment; it cannot change features or plugins") if extras: from pm.extras import extra_supported @@ -519,10 +518,26 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu ) package = get_package("venv") - from hermes_cli.runtime_state import runtime_lock, recover_publication + from hermes_cli.runtime_state import runtime_lock, recover_publication, finish_publication + from pm.publication import PluginSelection, StagedPlugin, candidate_members with runtime_lock(paths.repo_root()): recover_publication(paths.repo_root()) - members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs + if sum(value is not None for value in (selection, staged_plugin, plugin_dirs)) + bool(extra_plugin_dirs) > 1: + raise ValueError("publication owns plugin member discovery") + change = (PluginSelection(selection) if selection is not None else + StagedPlugin(staged_plugin) if staged_plugin is not None else None) + if isinstance(change, StagedPlugin) and not change.active: + try: + change.publish(paths.repo_root()) + finish_publication(paths.repo_root()) + except BaseException: + recover_publication(paths.repo_root()) + raise + receipt.record_venv_rebuild(False, "inactive plugin") + outcome = "ok" + return + members = (change.members if change is not None else + candidate_members(extra_plugin_dirs) if extra_plugin_dirs else plugin_dirs) inputs = {} if members is None else {"plugin_dirs": members} facts = Facts(paths.runtime_facts_path(), strict=repair) fact = facts.get("venv") or _facts().get("venv") or {} @@ -539,30 +554,21 @@ def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False, plu stamp = package.expected_stamp(enabled, **inputs) if not repair and not explicit and not lazy_installs_allowed() and not _runtime_state_matches(fact, stamp): raise _refuse_lazy("venv", str(extras) if extras else "venv out of sync") - if not repair and _runtime_state_matches(fact, stamp): - if before_publish is not None: - publication = before_publish() - if hasattr(publication, "finish"): - publication.finish() - receipt.record_venv_rebuild(False, "already in sync") - outcome = "ok" - return + current = not repair and _runtime_state_matches(fact, stamp) receipt.record_feature_list(enabled) - undo = None try: - result = package.apply(enabled, **inputs) or {} - if before_publish is not None: - undo = before_publish() - facts.record_state("venv", stamp, enabled, **result) - if hasattr(undo, "finish"): - undo.finish() - receipt.record_venv_rebuild(True) + result = {} if current else (package.apply(enabled, explicit=explicit, **inputs) or {}) + if not repair and package.expected_stamp(enabled, **inputs) != stamp: + raise ValueError("Dependency inputs changed while preparing publication; retry.") + if change is not None: + change.publish(paths.repo_root()) + if not current: + facts.record_state("venv", stamp, enabled, **result) + if change is not None: + finish_publication(paths.repo_root()) + receipt.record_venv_rebuild(not current, "already in sync" if current else "") except BaseException: - if undo is not None: - try: - undo() - except Exception: - LOG.exception("pm sync: publish undo failed; config may drift") + recover_publication(paths.repo_root()) raise outcome = "ok" except BaseException as exc: diff --git a/pm/package.py b/pm/package.py index 69cea6b3fb..b9c2c3c90d 100644 --- a/pm/package.py +++ b/pm/package.py @@ -351,7 +351,7 @@ class StatePackage(Package): def expected_stamp(self, extras: list[str]) -> str: raise NotImplementedError - def apply(self, extras: list[str]) -> None: + def apply(self, extras: list[str], *, explicit: bool = False) -> None: raise NotImplementedError diff --git a/pm/packages.py b/pm/packages.py index 04c444c745..acde350caa 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -397,7 +397,7 @@ class Venv(StatePackage): 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) -> dict: + def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False) -> dict: """Prepare one complete environment; the caller commits its selection.""" import uuid from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path @@ -408,7 +408,7 @@ class Venv(StatePackage): project = self.project_root() generation = install_state_dir(project) / "environments" / uuid.uuid4().hex candidate = generation / "venv" - environment = managed_environment(candidate, explicit=repair) + environment = managed_environment(candidate, explicit=explicit or repair) members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs) try: generation.mkdir(parents=True) diff --git a/pm/plugins_state.py b/pm/plugins_state.py index 25fc6cb086..14d50af510 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -102,7 +102,7 @@ def _all_homes() -> list[Path]: return homes -def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None) -> dict[Path, list[str]]: +def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None, installing: Path | None = None) -> dict[Path, list[str]]: """plugins_dir → ordered enabled list, per home. Keyed by the PLUGINS DIR (where the member dirs live), not the home itself. @@ -119,7 +119,7 @@ def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None) if proposed_home is not None and home.resolve() == Path(proposed_home).resolve(): config = {**config, "plugins": {"enabled": list(enabled or ()), "disabled": list(disabled or ())}} names = _enabled_from_config(config) - provider = _provider_from_config(home, config) + provider = _provider_from_config(home, config, installing=installing) if provider and provider not in names: names.append(provider) if names: @@ -127,11 +127,12 @@ def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None) return out -def _provider_from_config(home: Path, config: dict[str, Any]) -> Optional[str]: +def _provider_from_config(home: Path, config: dict[str, Any], *, installing: Path | None = None) -> Optional[str]: """The ``memory.provider`` key of an already-parsed config, when its plugin dir exists (no dir = not a member).""" provider = (config.get("memory") or {}).get("provider") if not provider or not provider.strip(): return None name = provider.strip() - return name if _is_directory(home / "plugins" / name) else None + return name if (_is_directory(home / "plugins" / name) or + (installing is not None and (home / "plugins" / name).resolve() == installing.resolve())) else None diff --git a/pm/publication.py b/pm/publication.py new file mode 100644 index 0000000000..9548ba90dd --- /dev/null +++ b/pm/publication.py @@ -0,0 +1,170 @@ +"""Worker-local plugin selection and code publication. No application dependency imports. + +Requests carry proposed data; discovery, snapshots and publication happen only +while the install lock is held. The stdlib boot journal owns crash recovery. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +from pathlib import Path + +from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path +from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest +from pm.workspace import enabled_plugin_dirs, _is_member_candidate + + +def candidate_members(extra_dirs=(), **selection): + selected = enabled_plugin_dirs(**selection) + for source in selected: + validate_manifest(source) + members = [source for source in selected if _is_member_candidate(source)] + for directory in extra_dirs: + directory = Path(directory) + if directory not in members and _is_member_candidate(directory): + members.append(directory) + return members + + +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()} + + +def validate_manifest(source: Path) -> dict: + # These parsers depend only on stdlib + PM's YAML runtime, never plugins_cmd + # or the application's config/UI dependency tree. + from hermes_yaml import safe_load, YAMLError + from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION, requires_hermes_error + + native = next((source / name for name in ("plugin.yaml", "plugin.yml") if (source / name).exists()), None) + try: + if native is not None: + manifest = safe_load(native.read_text(encoding="utf-8-sig")) + elif (source / "plugin.json").exists() or (source / "plugin.json").is_symlink(): + from hermes_cli.agent_plugins import read_agent_plugin_manifest + manifest, _ = read_agent_plugin_manifest(source) + else: + return {} + except (OSError, UnicodeError, YAMLError) as exc: + raise ValueError(f"Could not read plugin manifest {source}: {exc}") from exc + if not isinstance(manifest, dict): + raise ValueError(f"Plugin manifest must be a mapping: {source}") + reason = requires_hermes_error(manifest) + if reason: + raise ValueError(f"Plugin '{source.name}' {reason}") + if manifest.get("manifest_version") is not None: + try: + version = int(manifest["manifest_version"]) + except (ValueError, TypeError) as exc: + raise ValueError(f"Plugin '{source.name}' has invalid manifest_version") from exc + if version > SUPPORTED_MANIFEST_VERSION: + raise ValueError(f"Plugin '{source.name}' requires manifest_version {version}; update Hermes first") + return manifest + + +class PluginSelection: + def __init__(self, selection: dict): + from hermes_yaml import roundtrip_yaml + + self.configs = selection_snapshot() + self.home = Path(selection["home"]).resolve() + if not self.home.is_relative_to(dependency_home_root().resolve()): + raise ValueError("config path is outside Hermes state") + self.path = self.home / "config.yaml" + self.previous = _bytes(self.path) + expected = selection.get("expected_config") + actual = hashlib.sha256(self.previous).hexdigest() if self.previous is not None else "missing" + if expected is not None and expected != actual: + raise ValueError("Plugin configuration changed since this selection was read; retry.") + yaml = roundtrip_yaml() + config = yaml.load(self.previous.decode("utf-8-sig")) if self.previous else {} + if config is None: + config = {} + if not isinstance(config, dict): + raise ValueError(f"configuration must be a mapping: {self.path}") + plugins = config.setdefault("plugins", {}) + if not isinstance(plugins, dict): + raise ValueError(f"plugins must be a mapping in {self.path}") + plugins["enabled"] = sorted(selection["enabled"]) + plugins["disabled"] = sorted(selection["disabled"]) + output = io.StringIO() + yaml.dump(config, output) + self.proposed = output.getvalue().encode("utf-8") + self.members = candidate_members(selection.get("extra_dirs", ()), proposed_home=self.home, + enabled=selection["enabled"], disabled=selection["disabled"]) + + def publish(self, project: Path) -> None: + if selection_snapshot() != self.configs or _bytes(self.path) != self.previous: + raise ValueError("plugin configuration changed while preparing publication; retry") + row = {"config": str(self.path), + "previous": base64.b64encode(self.previous).decode() if self.previous is not None else None, + "facts_before": _digest(runtime_facts_path(project)), + "config_after": hashlib.sha256(self.proposed).hexdigest()} + _atomic_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode()) + _atomic_bytes(self.path, self.proposed) + + +class StagedPlugin: + def __init__(self, plugin: dict): + from pm.store import tree_digest + from pm.workspace import enabled_plugin_dirs, member_sources + + self.configs = selection_snapshot() + self.target = Path(plugin["target"]).absolute() + self.staged = Path(plugin["staged"]).resolve() + if (not self.target.resolve().is_relative_to(dependency_home_root().resolve()) + or self.target.parent.name != "plugins" or self.target.is_symlink() + or self.staged == self.target.resolve() or self.staged.is_relative_to(self.target.resolve()) + or self.target.resolve().is_relative_to(self.staged)): + raise ValueError("plugin publication paths escape or overlap their home") + manifest = validate_manifest(self.staged) + if manifest.get("name", self.target.name) != self.target.name: + raise ValueError("The updated plugin changed its installed name; reinstall it explicitly.") + self.staged_digest = tree_digest(self.staged) + self.metadata = self.target.parent / ".install-metadata.json" + self.previous = _bytes(self.metadata) + current = json.loads(self.previous) if self.previous is not None else {} + if current != plugin["old_metadata"]: + raise ValueError("Plugin install metadata changed while preparing the update; retry.") + self.target_digest = tree_digest(self.target) if self.target.exists() else None + if self.target_digest != plugin["target_digest"]: + raise ValueError("Plugin files changed while preparing the update; retry.") + self.proposed = (json.dumps(plugin["new_metadata"], indent=2, sort_keys=True) + "\n").encode() + sources = member_sources(enabled_plugin_dirs(installing=self.target)) + self.active = self.target.resolve() in sources + self.members = {} + if self.active: + sources[self.target.resolve()] = self.staged + for source in sources.values(): + validate_manifest(source) + self.members = {identity: source for identity, source in sources.items() if _is_member_candidate(source)} + + def publish(self, project: Path) -> None: + import os + import uuid + from pm.store import tree_digest + + if selection_snapshot() != self.configs: + raise ValueError("Plugin enablement changed while preparing the update; retry.") + if tree_digest(self.staged) != self.staged_digest: + raise ValueError("Staged plugin files changed while preparing the update; retry.") + if _bytes(self.metadata) != self.previous: + raise ValueError("Plugin install metadata changed while preparing the update; retry.") + current = tree_digest(self.target) if self.target.exists() else None + if current != self.target_digest: + raise ValueError("Plugin files changed while preparing the update; retry.") + backup = self.target.parent / f".previous-{uuid.uuid4().hex}" + row = { + "kind": "plugin", "target": str(self.target), "backup": str(backup), "metadata": str(self.metadata), + "target_existed": self.target.exists(), "facts_before": _digest(runtime_facts_path(project)), + "metadata_before": base64.b64encode(self.previous).decode() if self.previous is not None else None, + "metadata_after": base64.b64encode(self.proposed).decode(), + } + _atomic_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode()) + if self.target.exists(): + os.replace(self.target, backup) + os.replace(self.staged, self.target) + _atomic_bytes(self.metadata, self.proposed) diff --git a/pm/worker.py b/pm/worker.py index 45d7ca3860..1854140b07 100644 --- a/pm/worker.py +++ b/pm/worker.py @@ -92,37 +92,18 @@ def main(): raise RuntimeError(reply["error"]) return reply["result"] - def sync_venv(**arguments): - if "plugin_dirs" in request["callbacks"]: - arguments["plugin_dirs"] = lambda: _members(callback("plugin_dirs")) - else: - arguments["plugin_dirs"] = _members(arguments["plugin_dirs"]) - if "before_publish" in request["callbacks"]: - def publish(): - hooks = callback("before_publish") - if not any(hooks.values()): - return None - def undo(): - if hooks["undo"]: - callback("undo") - if hooks["finish"]: - undo.finish = lambda: callback("finish") - return undo - arguments["before_publish"] = publish - return engine.sync_venv(**arguments) - with receipt.worker_context(request.get("update_id")): try: load_package_definitions(request.get("packages", [])) from pm import operations as python - operations = {"ensure": engine.ensure, "sync_venv": sync_venv, + operations = {"ensure": engine.ensure, "sync_venv": engine.sync_venv, "stage_only": engine.stage_only, "venv_is_current": engine.venv_is_current, "build_environment": python.build_environment, "lock_project": python.lock_project, "stage_manager_runtime": python.stage_manager_runtime, "ensure_environment": python.ensure_environment, "ensure_python_tool": python.ensure_python_tool} arguments = request["arguments"] - if request["operation"] == "venv_is_current": + if request["operation"] in ("sync_venv", "venv_is_current"): arguments["plugin_dirs"] = _members(arguments.get("plugin_dirs")) if request["operation"] == "ensure": arguments["pause_event"] = pause diff --git a/pm/workspace.py b/pm/workspace.py index 5a29aed04e..2f6206d1ff 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -179,12 +179,12 @@ def _is_member_candidate(plugin_dir: Path) -> bool: return False -def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]: +def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None, installing: Path | None = None) -> list[Path]: """Resolve the effective plugin selection without filtering dependency declarations.""" from pm.plugins_state import _is_directory, enabled_plugins_ordered - selection = enabled_plugins_ordered() if proposed_home is None else enabled_plugins_ordered( - proposed_home=proposed_home, enabled=enabled, disabled=disabled, + selection = enabled_plugins_ordered( + proposed_home=proposed_home, enabled=enabled, disabled=disabled, installing=installing, ) members = [] for plugins_dir, names in selection.items(): @@ -193,9 +193,10 @@ def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None) -> l if relative.is_absolute() or ".." in relative.parts: raise InstallError("venv", f"invalid plugin key: {name}") plugin_dir = plugins_dir / relative - if not _is_directory(plugin_dir): + proposed = installing is not None and plugin_dir.resolve() == installing.resolve() + if not proposed and not _is_directory(plugin_dir): plugin_dir = paths.repo_root() / "plugins" / relative - if _is_directory(plugin_dir): + if proposed or _is_directory(plugin_dir): members.append(plugin_dir) return list(dict.fromkeys(members)) diff --git a/tests/hermes_cli/test_plugin_update_recovery.py b/tests/hermes_cli/test_plugin_update_recovery.py index eb89e35d45..c5e447f74f 100644 --- a/tests/hermes_cli/test_plugin_update_recovery.py +++ b/tests/hermes_cli/test_plugin_update_recovery.py @@ -28,11 +28,14 @@ def test_boot_recovers_plugin_publication_after_process_death(tmp_path, committe program = ''' from pathlib import Path import os,sys -from hermes_cli.plugins_transaction import PluginPublication +from pm.publication import StagedPlugin +from pm.store import tree_digest from hermes_cli.runtime_paths import runtime_facts_path from pm.lock import Facts project,staged,target = map(Path,sys.argv[1:4]) -pub = PluginPublication(project,staged,target,{"example":{"revision":"new"}}) +StagedPlugin({"staged": str(staged), "target": str(target), "target_digest": tree_digest(target), + "old_metadata": {"example":{"revision":"old"}}, + "new_metadata": {"example":{"revision":"new"}}}).publish(project) if sys.argv[4] == "True": Facts(runtime_facts_path(project)).record_state("venv","new",[]) os._exit(17) diff --git a/tests/hermes_cli/test_plugins_admission_setter.py b/tests/hermes_cli/test_plugins_admission_setter.py index 21b1ad1e83..e409829ac5 100644 --- a/tests/hermes_cli/test_plugins_admission_setter.py +++ b/tests/hermes_cli/test_plugins_admission_setter.py @@ -1,7 +1,7 @@ """C13 enable admission: every UI path's proposed enabled/disabled sets go through ONE authority — the candidate union resolves against the active environment and the config commits inside pm's single locked transaction -(sync's before_publish hook). Refusal (dep conflict OR config-write +(worker-owned publication). Refusal (dep conflict OR config-write failure) publishes nothing: previous config bytes and previous environment stay exactly in place. Real temp HERMES_HOME — no live user writes.""" @@ -54,11 +54,15 @@ def sync_calls(monkeypatch): from pm import client calls = [] - def _sync(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None): - members = plugin_dirs() if callable(plugin_dirs) else plugin_dirs - calls.append(list(members or [])) - if before_publish is not None: - before_publish() # same contract: config commits under the sync + def _sync(extras=None, *, explicit=False, selection=None): + from pm.publication import PluginSelection + from pm.paths import repo_root + from hermes_cli.runtime_state import runtime_lock, finish_publication + with runtime_lock(repo_root()): + change = PluginSelection(selection) + calls.append(list(change.members)) + change.publish(repo_root()) + finish_publication(repo_root()) monkeypatch.setattr(client, "sync_venv", _sync) return calls @@ -157,7 +161,7 @@ def test_dashboard_enable_refusal_reports_not_ok(plugin_home, monkeypatch, sync_ assert _enabled_set(plugin_home) == set() -# ── the before_publish hook: config commits under the lock, undo on facts failure ─ +# ── worker-owned publication: exact rollback on facts failure ─ def test_config_commit_undo_restores_previous_bytes(plugin_home): @@ -165,14 +169,17 @@ def test_config_commit_undo_restores_previous_bytes(plugin_home): config_path = plugin_home / "config.yaml" previous = config_path.read_bytes() - undo = adm._config_commit({"dep-plug"}, set()) + from pm.publication import PluginSelection + from pm.paths import repo_root + from hermes_cli.runtime_state import recover_publication + PluginSelection({"home": str(plugin_home), "enabled": ["dep-plug"], "disabled": []}).publish(repo_root()) assert _enabled_set(plugin_home) == {"dep-plug"} # committed exactly once - undo() # facts write failed afterwards → restore + recover_publication(repo_root()) # facts write failed afterwards → restore assert config_path.read_bytes() == previous def test_facts_failure_triggers_undo_inside_one_transaction(plugin_home, monkeypatch): - """Real sync_venv: apply stages → before_publish commits config → facts + """Real sync_venv: apply stages → worker commits config → facts write fails → undo restores the previous config bytes atomically; the receipt records the failure.""" import importlib @@ -199,10 +206,6 @@ def test_facts_failure_triggers_undo_inside_one_transaction(plugin_home, monkeyp order = [] - def before_publish(): - order.append("before_publish") - return adm._config_commit({"dep-plug"}, set()) - facts_path = plugin_home / "runtime" / "facts.json" ensure.Facts(facts_path).record_state("venv", "previous-stamp", []) previous_facts = facts_path.read_bytes() @@ -210,13 +213,14 @@ def test_facts_failure_triggers_undo_inside_one_transaction(plugin_home, monkeyp monkeypatch.setattr(ensure.paths, "repo_root", lambda: plugin_home / "runtime") def fail_publication(self, *args, **kwargs): + assert _enabled_set(plugin_home) == {"dep-plug"} order.append("record_state") raise OSError("facts disk full") monkeypatch.setattr(ensure.Facts, "record_state", fail_publication) with pytest.raises(OSError, match="facts disk full"): - ensure.sync_venv(explicit=True, plugin_dirs=[plug], before_publish=before_publish) - assert order == ["before_publish", "record_state"] # config committed under the lock first + ensure.sync_venv(explicit=True, selection={"home": str(plugin_home), "enabled": ["dep-plug"], "disabled": []}) + assert order == ["record_state"] # config committed under the lock first assert (plugin_home / "config.yaml").read_bytes() == previous # undone atomically assert facts_path.read_bytes() == previous_facts latest = json.loads((rdir / "latest.json").read_text(encoding="utf-8-sig")) @@ -254,9 +258,8 @@ def test_noop_sync_writes_ok_receipt_rebuild_false(plugin_home, monkeypatch): rdir = plugin_home / "receipts" monkeypatch.setattr("pm.receipt._receipt_dir", lambda: rdir) monkeypatch.setattr(ensure, "_runtime_state_matches", lambda fact, stamp: True) - committed = [] - ensure.sync_venv(extras=[], before_publish=lambda: committed.append(True)) - assert committed == [True], "unchanged dependencies must still commit a plugin selection" + ensure.sync_venv(extras=[], selection={"home": str(plugin_home), "enabled": ["plain"], "disabled": []}) + assert _enabled_set(plugin_home) == {"plain"}, "unchanged dependencies must still commit a plugin selection" latest = json.loads((rdir / "latest.json").read_text(encoding="utf-8-sig")) assert latest["outcome"] == "ok" assert latest["venv_rebuild"]["ok"] is False diff --git a/tests/hermes_cli/test_plugins_update_sync.py b/tests/hermes_cli/test_plugins_update_sync.py index e085f73dad..a1a650fcb6 100644 --- a/tests/hermes_cli/test_plugins_update_sync.py +++ b/tests/hermes_cli/test_plugins_update_sync.py @@ -43,8 +43,8 @@ def test_disabled_update_does_not_change_dependencies_or_enablement(installed, m config = (home / "config.yaml").read_bytes() facts = paths.runtime_facts_path().read_bytes() state["sha"] = _version(repo, "2.0.0") - monkeypatch.setattr("pm.client.sync_venv", - lambda **kwargs: pytest.fail("disabled plugin changed the dependency selection")) + monkeypatch.setattr("pm.packages.Venv.apply", + lambda *args, **kwargs: pytest.fail("disabled plugin changed the dependency selection")) result = pc.dashboard_update_user_plugin("transactional") assert result["ok"], result assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=target, text=True).strip() == state["sha"] diff --git a/tests/hermes_cli/test_tools_config_post_setup.py b/tests/hermes_cli/test_tools_config_post_setup.py index ef19c8105e..7ab2bd51c6 100644 --- a/tests/hermes_cli/test_tools_config_post_setup.py +++ b/tests/hermes_cli/test_tools_config_post_setup.py @@ -138,12 +138,16 @@ def test_langfuse_setup_uses_plugin_admission_and_preserves_config_on_refusal( def resolve_candidate(**kwargs): assert kwargs["explicit"] is True - kwargs["plugin_dirs"]() + assert kwargs["selection"]["enabled"] if failure == "admission": raise pm.InstallError("venv", "candidate refused") # The real admission publisher must commit both lists, not a second UI writer. - publication = kwargs["before_publish"]() - publication.finish() + from pm.publication import PluginSelection + from pm.paths import repo_root + from hermes_cli.runtime_state import runtime_lock, finish_publication + with runtime_lock(repo_root()): + PluginSelection(kwargs["selection"]).publish(repo_root()) + finish_publication(repo_root()) with ( patch("pm.sync_venv", side_effect=pm.InstallError("venv", "SDK refused") if failure == "sdk" else None) as sdk, diff --git a/tests/pm/test_admission_members_locked.py b/tests/pm/test_admission_members_locked.py index d739d17d4d..51ca7296da 100644 --- a/tests/pm/test_admission_members_locked.py +++ b/tests/pm/test_admission_members_locked.py @@ -35,13 +35,13 @@ def test_admission_reads_other_profiles_after_taking_lock(tmp_path, monkeypatch) def members(*args, **kwargs): return list(state["members"]) - def apply(extras, *, plugin_dirs): + def apply(extras, *, plugin_dirs, explicit=False): assert state["inside"] assert plugin_dirs == [sibling] return {} monkeypatch.setattr(runtime_state, "runtime_lock", after_competing_publication) - monkeypatch.setattr(admission, "candidate_member_dirs", members) + monkeypatch.setattr("pm.publication.candidate_members", members) monkeypatch.setattr(ensure, "get_package", lambda _: SimpleNamespace( expected_stamp=lambda *args, **kwargs: "new", apply=apply, )) diff --git a/tests/pm/test_pm_core.py b/tests/pm/test_pm_core.py index c4e7e8c0c3..97f5b7caa7 100644 --- a/tests/pm/test_pm_core.py +++ b/tests/pm/test_pm_core.py @@ -608,7 +608,7 @@ class FakeVenv(StatePackage): h.update(",".join(sorted(extras)).encode()) return h.hexdigest() - def apply(self, extras): + def apply(self, extras, *, explicit=False): self.applied.append(list(extras)) from hermes_cli.runtime_paths import install_state_dir diff --git a/tests/pm/test_runtime_context_home.py b/tests/pm/test_runtime_context_home.py index a79791ed10..54421a2985 100644 --- a/tests/pm/test_runtime_context_home.py +++ b/tests/pm/test_runtime_context_home.py @@ -1,7 +1,7 @@ """Context-only homes use the same dependency state as their own process.""" from hermes_cli import runtime_paths -from hermes_cli.plugins_admission import _config_commit +from pm.publication import PluginSelection from hermes_cli.runtime_state import recover_publication, runtime_lock from hermes_constants import reset_hermes_home_override, set_hermes_home_override from pm import paths, plugins_state @@ -27,7 +27,7 @@ def test_context_home_publication_recovers_from_its_own_process(tmp_path, monkey context_home / "plugins": ["old"], } with runtime_lock(project): - _config_commit({"new"}, set()) + PluginSelection({"home": str(config.parent), "enabled": ["new"], "disabled": []}).publish(project) assert config.read_bytes() != original finally: reset_hermes_home_override(token) diff --git a/tests/pm/test_runtime_journal_concurrent_edit.py b/tests/pm/test_runtime_journal_concurrent_edit.py index 1b92f2f841..ca8c68ed1e 100644 --- a/tests/pm/test_runtime_journal_concurrent_edit.py +++ b/tests/pm/test_runtime_journal_concurrent_edit.py @@ -4,7 +4,7 @@ import pytest def test_recovery_refuses_to_replace_newer_config(tmp_path, monkeypatch): from hermes_cli.runtime_state import recover_publication, runtime_lock - from hermes_cli.plugins_admission import _config_commit + from pm.publication import PluginSelection import pm.paths as paths from hermes_cli.runtime_paths import install_state_dir @@ -15,7 +15,7 @@ def test_recovery_refuses_to_replace_newer_config(tmp_path, monkeypatch): config = tmp_path / "config.yaml" config.write_bytes(b"plugins:\n enabled: [old]\n") with runtime_lock(repo): - _config_commit({"new"}, set()) + PluginSelection({"home": str(config.parent), "enabled": ["new"], "disabled": []}).publish(repo) config.write_bytes(config.read_bytes() + b"model: user-choice\n") changed = config.read_bytes() with pytest.raises(RuntimeError, match="config changed"): diff --git a/tests/pm/test_runtime_public.py b/tests/pm/test_runtime_public.py index 2cfa4b623a..d1883c308e 100644 --- a/tests/pm/test_runtime_public.py +++ b/tests/pm/test_runtime_public.py @@ -13,7 +13,7 @@ def test_public_mutations_delegate_but_environment_reads_stay_local(tmp_path, mo monkeypatch.setattr(engine, "ensure", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline install"))) monkeypatch.setattr(engine, "sync_venv", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline sync"))) monkeypatch.setattr(client, "is_runtime", lambda: False, raising=False) - pm.sync_venv(["all"], explicit=True, plugin_dirs=lambda: (_ for _ in ()).throw(AssertionError("inline sync"))) + pm.sync_venv(["all"], explicit=True, plugin_dirs=[]) pm.ensure("node", explicit=True) assert calls == ["sync_venv", "ensure"] assert pm.env_for is engine.env_for diff --git a/tests/pm/test_runtime_recovery.py b/tests/pm/test_runtime_recovery.py index 7f534943b1..6e396f9ff5 100644 --- a/tests/pm/test_runtime_recovery.py +++ b/tests/pm/test_runtime_recovery.py @@ -22,13 +22,13 @@ def test_killed_publication_recovers_before_boot(tmp_path, commit_facts): code = ''' import os, sys from pathlib import Path -from hermes_cli.plugins_admission import _config_commit +from pm.publication import PluginSelection from hermes_cli.runtime_paths import runtime_facts_path import pm.paths as paths from pm.lock import Facts repo, config = map(Path, sys.argv[1:3]) paths.repo_root = lambda: repo -change = _config_commit({"new"}, set()) +PluginSelection({"home": str(config.parent), "enabled": ["new"], "disabled": []}).publish(repo) if sys.argv[3] == "True": Facts(runtime_facts_path(repo)).record_state("venv", "new", []) os._exit(17) diff --git a/tests/pm/test_runtime_transaction.py b/tests/pm/test_runtime_transaction.py index b9b36db336..0585d78558 100644 --- a/tests/pm/test_runtime_transaction.py +++ b/tests/pm/test_runtime_transaction.py @@ -49,7 +49,7 @@ def test_sync_commits_only_a_successful_candidate(tmp_path, monkeypatch, failure def expected_stamp(self, extras): return "new" - def apply(self, extras): + def apply(self, extras, *, explicit=False): if failure != "missing": assert selected_venv(root) == previous assert extras == ["base", "new-extra"] diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py index fc674cd9bd..8e8b046ea3 100644 --- a/tests/pm/test_worker.py +++ b/tests/pm/test_worker.py @@ -164,19 +164,12 @@ def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, m if route != "direct": engine = importlib.import_module("pm.ensure") monkeypatch.setattr(engine, "venv_is_current", lambda **kw: pytest.fail("probe ran in caller")) - callbacks = [] - - def select(): - callbacks.append("selected") - return members - def snapshot(): return {path.relative_to(tmp_path): (path.read_bytes() if path.is_file() else None) for path in tmp_path.rglob("*")} before = snapshot() - assert client.venv_is_current(extras=["provider-extra"], plugin_dirs=select, **root_args) - assert callbacks == ["selected"] + assert client.venv_is_current(extras=["provider-extra"], plugin_dirs=members, **root_args) assert client.venv_is_current(extras=[], plugin_dirs=members, **root_args) assert not client.venv_is_current(extras=["new-extra"], plugin_dirs=members, **root_args) assert not client.venv_is_current(extras=recorded, plugin_dirs=[], **root_args) @@ -185,7 +178,7 @@ def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, m manifest.write_text('name: candidate\npython_dependencies: ["candidate-dep==2"]\n') changed = snapshot() - assert not client.venv_is_current(extras=["provider-extra"], plugin_dirs=select, **root_args) + assert not client.venv_is_current(extras=["provider-extra"], plugin_dirs=members, **root_args) assert snapshot() == changed assert selected_venv(repo) == environment # Corruption must not be mistaken for a missing or current environment. @@ -207,34 +200,35 @@ def _assert_worker_holds_lock(repo): @pytest.mark.parametrize("explicit", [True, False]) -def test_sync_callbacks_preserve_member_mapping_and_lock(client, tmp_path, monkeypatch, explicit): - identity, staged = tmp_path / "installed", tmp_path / "staged" - staged.mkdir() - (staged / "plugin.yaml").write_text("name: test\n") - members = {identity: staged} - repo = _current_environment(tmp_path, monkeypatch, members) - events = [] +def test_sync_discovers_profile_members_after_worker_acquires_lock(client, tmp_path, monkeypatch, isolated_python, explicit): + from concurrent.futures import ThreadPoolExecutor + import time + from hermes_cli.runtime_state import runtime_lock + from tests.pm.test_worker_publication import worker_toolchain - def select(): - _assert_worker_holds_lock(repo) - events.append("members") - return members - - class Publication: - def __call__(self): - pytest.fail("successful no-op publication was undone") - - def finish(self): - _assert_worker_holds_lock(repo) - events.append("finish") - - def before_publish(): - _assert_worker_holds_lock(repo) - events.append("publish") - return Publication() - - client.sync_venv([], explicit=explicit, plugin_dirs=select, before_publish=before_publish) - assert events == ["members", "publish", "finish"] + sibling = tmp_path / "home/profiles/sibling/plugins/dependency" + sibling.mkdir(parents=True) + (sibling / "plugin.yaml").write_text("name: dependency\npython_dependencies: []\n") + repo = _current_environment(tmp_path, monkeypatch, [sibling]) + ready = tmp_path / "waiting-for-lock" + worker_toolchain(client, monkeypatch, isolated_python, + "from contextlib import contextmanager\nimport hermes_cli.runtime_state as state\n" + "original = state.runtime_lock\n@contextmanager\ndef lock(project):\n" + f" Path({str(ready)!r}).touch()\n" + " with original(project):\n yield\nstate.runtime_lock = lock\n") + with ThreadPoolExecutor() as executor: + with runtime_lock(repo): + future = executor.submit(client.sync_venv, explicit=explicit, selection={ + "home": str(tmp_path / "home"), "enabled": ["plain"], "disabled": [], + }) + deadline = time.monotonic() + 15 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert ready.exists(), "worker never reached the install lock" + assert not future.done(), "worker ignored the install lock" + (sibling.parent.parent / "config.yaml").write_text("plugins:\n enabled: [dependency]\n") + future.result(timeout=30) + assert client.venv_is_current() @pytest.mark.parametrize("current", [True, False]) @@ -262,19 +256,11 @@ def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypat monkeypatch.setattr(_uv, "_toolchain", toolchain) monkeypatch.setattr("pm.runtime_stage.stage_runtime", lambda *a, **kw: pytest.fail("lazy-disabled sync prepared PM runtime")) - selections = [] - - def select(): - _assert_worker_holds_lock(repo) - selections.append("selected") - return [] - with receipt.worker_context("lazy-disabled-sync"): with pytest.raises(InstallError, match="lazy installs are disabled") as caught: - client.sync_venv([], plugin_dirs=select) + client.sync_venv([], plugin_dirs=[]) result = receipt.last_for_update("lazy-disabled-sync", consume=True) assert caught.value.package == "pm-runtime" - assert selections == [] assert result is not None assert result["outcome"] == "failed" receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) @@ -283,22 +269,17 @@ def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypat assert not paths.facts_path().exists() -def test_callback_exception_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch): +def test_invalid_selection_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch): import json from hermes_cli.runtime_paths import install_state_dir from hermes_cli.runtime_state import _lock repo = _current_environment(tmp_path, monkeypatch, []) - error = LookupError("selection disappeared") - - def fail(): - _assert_worker_holds_lock(repo) - raise error - - with pytest.raises(LookupError) as caught: - client.sync_venv([], explicit=True, plugin_dirs=fail) - assert caught.value is error - receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + home = tmp_path / "home" + (home / "config.yaml").write_text("plugins: []\n") + with pytest.raises(ValueError, match="plugins must be a mapping"): + client.sync_venv([], explicit=True, selection={"home": str(home), "enabled": [], "disabled": []}) + receipts = list((home / "logs" / "update_receipts").glob("pm_*.json")) assert len(receipts) == 1 assert json.loads(receipts[0].read_text())["outcome"] == "failed" with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: @@ -483,33 +464,28 @@ def test_resolution_conflict_survives_worker_and_receipt(client, tmp_path, monke assert "engine stdout" in capfd.readouterr().err -def test_failed_finish_runs_undo_before_propagating_callback_exception(client, tmp_path, monkeypatch, isolated_python): +def test_failed_facts_write_restores_exact_config_before_reporting(client, tmp_path, monkeypatch, isolated_python): + from tests.pm.test_worker_publication import worker_toolchain + from hermes_cli.runtime_paths import install_state_dir + repo = _current_environment(tmp_path, monkeypatch, []) + home = tmp_path / "home" + config = home / "config.yaml" + config.write_text("# preserve me\nplugins: {enabled: [old]}\n") + previous = config.read_bytes() + facts = (install_state_dir(repo) / "facts.json").read_bytes() (repo / "uv.lock").write_text("version = 2\n") - _patch_worker_apply(client, monkeypatch, isolated_python, "return {}") - events = [] - error = LookupError("finish failed") - - class Publication: - def __call__(self): - _assert_worker_holds_lock(repo) - events.append("undo") - return object() # Return values of effect-only callbacks are ignored. - - def finish(self): - _assert_worker_holds_lock(repo) - events.append("finish") - raise error - - def publish(): - _assert_worker_holds_lock(repo) - events.append("publish") - return Publication() - - with pytest.raises(LookupError) as caught: - client.sync_venv([], explicit=True, plugin_dirs=[], before_publish=publish) - assert caught.value is error - assert events == ["publish", "finish", "undo"] + worker_toolchain(client, monkeypatch, isolated_python, + "from pm.packages import Venv\nfrom pm.lock import Facts\n" + "Venv.apply = lambda *args, **kwargs: {}\n" + "def fail(*args, **kwargs):\n" + f" assert b'new' in Path({str(config)!r}).read_bytes()\n" + " raise OSError('facts disk full')\nFacts.record_state = fail\n") + with pytest.raises(OSError, match="facts disk full"): + client.sync_venv(explicit=True, selection={"home": str(home), "enabled": ["new"], "disabled": []}) + assert config.read_bytes() == previous + assert (install_state_dir(repo) / "facts.json").read_bytes() == facts + assert not (install_state_dir(repo) / "publication.json").exists() def test_invalid_arguments_keep_the_engine_exception_type(client): diff --git a/tests/pm/test_worker_publication.py b/tests/pm/test_worker_publication.py new file mode 100644 index 0000000000..fb05cda655 --- /dev/null +++ b/tests/pm/test_worker_publication.py @@ -0,0 +1,386 @@ +"""Plugin publication runs in PM's independent worker, not application callbacks.""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.pm.test_worker import client, isolated_python, _current_environment # noqa: F401 + + +def test_worker_publishes_selection_even_when_dependencies_are_current(client, tmp_path, monkeypatch): + from hermes_cli.runtime_paths import install_state_dir + from pm import receipt + + home = tmp_path / "home" + home.mkdir() + config = home / "config.yaml" + config.write_text('# keep my comment\nmodel: "untouched"\nplugins:\n enabled: [old]\n') + repo = _current_environment(tmp_path, monkeypatch, []) + facts = (install_state_dir(repo) / "facts.json").read_bytes() + with receipt.worker_context("selection-publication"): + client.sync_venv(explicit=True, selection={ + "home": str(home), "enabled": ["plain"], "disabled": ["old"], "extra_dirs": [], + }) + result = receipt.last_for_update("selection-publication", consume=True) + from utils import fast_safe_load + assert fast_safe_load(config.read_text())["plugins"] == {"enabled": ["plain"], "disabled": ["old"]} + assert '# keep my comment' in config.read_text() + assert 'model: "untouched"' in config.read_text() + assert (install_state_dir(repo) / "facts.json").read_bytes() == facts + assert not (install_state_dir(repo) / "publication.json").exists() + assert result is not None + assert result["outcome"] == "ok" + assert result["venv_rebuild"]["ok"] is False + assert json.loads((home / "logs/update_receipts/latest.json").read_text())["outcome"] == "ok" + + +def worker_toolchain(client, monkeypatch, isolated_python, injection=""): + uv = shutil.which("uv") + assert uv + worker = Path(client.__file__).with_name("worker.py") + script = ( + "import runpy, sys, os; from pathlib import Path; " + f"sys.path.insert(0, {str(worker.parent.parent)!r}); " + "import pm._uv; " + f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r}));\n" + + injection + f"\nrunpy.run_path({str(worker)!r}, run_name='__main__')" + ) + monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script]) + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("missing", [False, True], ids=["existing", "missing"]) +def test_staged_plugin_publication_uses_installed_identity_and_local_dependencies( + client, tmp_path, monkeypatch, isolated_python, active, missing, +): + from hermes_cli.runtime_paths import install_state_dir, selected_venv + from pm.store import tree_digest + from tests.pm.test_environment_build import _wheel + from pm import paths + + worker_toolchain(client, monkeypatch, isolated_python) + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: project) + (project / "pyproject.toml").write_text( + '[project]\nname="publication-core"\nversion="1"\nrequires-python=">=3.11"\n' + '[tool.uv]\npackage=false\n') + client.lock_project(project, offline=True, explicit=True) + home = tmp_path / "home" + target = home / "plugins" / "example" + target.mkdir(parents=True) + (home / "config.yaml").write_text('plugins:\n enabled: ' + ('[example]' if active else '[]') + '\n') + (target / "plugin.yaml").write_text("name: example\n") + (target / "code.py").write_text('old code') + metadata = target.parent / ".install-metadata.json" + metadata.write_text('{"example":{"revision":"old"}}\n') + client.sync_venv(explicit=True) + previous = selected_venv(project) + if missing: + shutil.rmtree(target) + staged = tmp_path / "staged" + staged.mkdir() + wheel = _wheel(tmp_path, "publication_dep") + (staged / "plugin.yaml").write_text('name: example\npython_dependencies: [' + json.dumps(f"publication-dep @ {wheel.as_uri()}") + ']\n') + (staged / "code.py").write_text('new code') + client.sync_venv(explicit=True, staged_plugin={ + "staged": str(staged), "target": str(target), "target_digest": tree_digest(target) if target.exists() else None, + "old_metadata": {"example": {"revision": "old"}}, + "new_metadata": {"example": {"revision": "new"}}, + }) + assert (target / "code.py").read_text() == "new code" + assert not staged.exists() + assert json.loads(metadata.read_text())["example"]["revision"] == "new" + selected = selected_venv(project) + assert (selected != previous) is active + if active: + python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + probe = subprocess.run([str(python), "-I", "-c", "import publication_dep; print(publication_dep.__version__)"], + text=True, capture_output=True, timeout=30) + assert probe.returncode == 0, probe.stderr + assert probe.stdout.strip() == "1.0" + facts = (install_state_dir(project) / "facts.json").read_bytes() + client.sync_venv(explicit=True) + assert (install_state_dir(project) / "facts.json").read_bytes() == facts + assert not (install_state_dir(project) / "publication.json").exists() + assert not list(target.parent.glob(".previous-*")) + + +@pytest.mark.parametrize("mutation", ["sibling", "active"]) +def test_selection_refuses_config_edits_during_preparation(client, tmp_path, monkeypatch, isolated_python, mutation): + from hermes_cli.runtime_paths import install_state_dir + home = tmp_path / "home" + home.mkdir() + config = home / "config.yaml" + config.write_text("plugins:\n enabled: [old]\n") + repo = _current_environment(tmp_path, monkeypatch, []) + (repo / "uv.lock").write_text("version = 2\n") + edited = config if mutation == "active" else home / "profiles/sibling/config.yaml" + expected = "plugins:\n enabled: [concurrent]\n" + facts = (install_state_dir(repo) / "facts.json").read_bytes() + injection = ( + "from pm.packages import Venv\n" + "def apply(self, *args, **kwargs):\n" + f" p=Path({str(edited)!r}); p.parent.mkdir(parents=True, exist_ok=True); p.write_text({expected!r})\n" + " return {}\n" + "Venv.apply=apply\n" + ) + worker_toolchain(client, monkeypatch, isolated_python, injection) + with pytest.raises(ValueError, match="changed"): + client.sync_venv(explicit=True, selection={"home": str(home), "enabled": ["new"], "disabled": []}) + assert edited.read_text() == expected + assert (install_state_dir(repo) / "facts.json").read_bytes() == facts + assert not (install_state_dir(repo) / "publication.json").exists() + + +@pytest.mark.parametrize("invalid", ["name: [", "manifest_version: 999", "requires_hermes: '>=999'", "name: other"]) +def test_worker_rejects_unloadable_staged_plugin_without_app_dependencies(client, tmp_path, monkeypatch, invalid): + from pm.store import tree_digest + repo = _current_environment(tmp_path, monkeypatch, []) + target = tmp_path / "home/plugins/example" + target.mkdir(parents=True) + (target / "plugin.yaml").write_text("name: example\n") + staged = tmp_path / "staged" + staged.mkdir() + (staged / "plugin.yaml").write_text(invalid) + previous = tree_digest(target) + with pytest.raises(ValueError): + client.sync_venv(explicit=True, staged_plugin={ + "target": str(target), "staged": str(staged), "target_digest": previous, + "old_metadata": {}, "new_metadata": {"example": {"revision": "new"}}, + }) + assert tree_digest(target) == previous + assert staged.exists() + + +def test_additional_candidates_are_discovered_by_sync_and_passive_probe(client, tmp_path, monkeypatch): + candidate = tmp_path / "candidate" + candidate.mkdir() + (candidate / "plugin.yaml").write_text("name: candidate\npython_dependencies: []\n") + _current_environment(tmp_path, monkeypatch, [candidate]) + assert client.venv_is_current(extra_plugin_dirs=[candidate]) + assert not client.venv_is_current() + client.sync_venv(explicit=True, extra_plugin_dirs=[candidate]) + assert client.venv_is_current(extra_plugin_dirs=[candidate]) + + +def test_memory_setup_sends_candidate_paths_instead_of_discovery_callbacks(tmp_path, monkeypatch): + from hermes_cli.memory_setup import memory_provider_dependency_inputs + candidate = tmp_path / "provider" + candidate.mkdir() + (candidate / "plugin.yaml").write_text("name: provider\npython_dependencies: []\n") + monkeypatch.setattr("plugins.memory.find_provider_dir", lambda name: candidate) + _, inputs = memory_provider_dependency_inputs("provider") + assert inputs == {"extras": [], "extra_plugin_dirs": [candidate]} + + +@pytest.mark.parametrize(("kind", "rebuild", "phase"), [ + (kind, rebuild, phase) + for kind in ("selection", "tree") for rebuild in (False, True) for phase in ("journal", "payload", "commit") +] + [("tree", rebuild, phase) for rebuild in (False, True) for phase in ("backup", "tree")]) +def test_worker_death_recovers_at_each_durable_publication_boundary( + client, tmp_path, monkeypatch, isolated_python, kind, rebuild, phase, +): + from hermes_cli.runtime_paths import install_state_dir, selected_venv + from pm import paths + from pm.package import InstallError + from pm.store import tree_digest + + worker_toolchain(client, monkeypatch, isolated_python) + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: project) + core = '[project]\nname="death-proof"\nversion="1"\nrequires-python=">=3.11"\n[tool.uv]\npackage=false\n' + (project / "pyproject.toml").write_text(core) + client.lock_project(project, offline=True, explicit=True) + home = tmp_path / "home" + target = home / "plugins/example" + target.mkdir(parents=True) + (target / "plugin.yaml").write_text("name: example\n") + (target / "code.py").write_text("old") + config = home / "config.yaml" + config.write_text("# exact bytes\nplugins:\n enabled: " + ("[example]" if kind == "tree" else "[]") + "\n") + metadata = target.parent / ".install-metadata.json" + metadata.write_text('{"example":{"revision":"old"}}\n') + client.sync_venv(explicit=True) + before_env = selected_venv(project) + state = install_state_dir(project) + facts = state / "facts.json" + before = {p: p.read_bytes() for p in (config, metadata, facts)} + before_tree = tree_digest(target) + if kind == "selection": + if rebuild: + (target / "pyproject.toml").write_text(core.replace("death-proof", "example")) + arguments = {"selection": {"home": str(home), "enabled": ["example"], "disabled": []}} + payload = config + else: + staged = tmp_path / "staged" + shutil.copytree(target, staged) + (staged / "code.py").write_text("new") + if rebuild: + (staged / "pyproject.toml").write_text(core.replace("death-proof", "example")) + arguments = {"staged_plugin": {"target": str(target), "staged": str(staged), "target_digest": before_tree, + "old_metadata": {"example": {"revision": "old"}}, "new_metadata": {"example": {"revision": "new"}}}} + payload = metadata + # Exit immediately after the real durable write, not a simulated publication. + injection = ( + "import json\nimport pm.publication as publication\nimport hermes_cli.runtime_state as state\n" + "original = state._atomic_bytes\n" + "def write(path, data):\n original(path, data)\n" + f" if {phase!r} == 'journal' and path.name == 'publication.json': os._exit(17)\n" + f" if {phase!r} == 'payload' and path == Path({str(payload)!r}): os._exit(17)\n" + f" if {phase!r} == 'commit' and path.name == 'publication.json' and json.loads(data).get('committed'): os._exit(17)\n" + "publication._atomic_bytes = state._atomic_bytes = write\n" + "original_replace = os.replace\ndef replace(source, target):\n original_replace(source, target)\n" + f" if {phase!r} == 'backup' and Path(target).name.startswith('.previous-'): os._exit(17)\n" + f" if {phase!r} == 'tree' and Path(target) == Path({str(target)!r}): os._exit(17)\n" + "os.replace = replace\n" + ) + if phase == "commit" and rebuild: + injection += ("from pm.lock import Facts\nrecord = Facts.record_state\n" + "def commit(self, *args, **kwargs):\n record(self, *args, **kwargs)\n os._exit(17)\n" + "Facts.record_state = commit\n") + worker_toolchain(client, monkeypatch, isolated_python, injection) + with pytest.raises(InstallError, match="worker exited without a result"): + client.sync_venv(explicit=True, **arguments) + assert (state / "publication.json").exists() + source = Path(client.__file__).resolve().parent.parent + program = (f"import sys; sys.path.insert(0, {str(source)!r}); from pathlib import Path; " + "from hermes_cli.runtime_state import runtime_lock, recover_publication; " + f"project = Path({str(project)!r})\n" + "with runtime_lock(project):\n recover_publication(project)\n recover_publication(project)\n") + recovery = subprocess.run([sys.executable, "-I", "-S", "-c", program], capture_output=True, text=True, + env=dict(os.environ), timeout=30) + assert recovery.returncode == 0, recovery.stderr + committed = phase == "commit" + assert (selected_venv(project) != before_env) is (committed and rebuild) + if not committed: + assert {p: p.read_bytes() for p in before} == before + if kind == "tree": + assert (target / "code.py").read_text() == ("new" if committed else "old") + if not committed: + assert tree_digest(target) == before_tree + else: + from utils import fast_safe_load + assert fast_safe_load(config.read_text())["plugins"]["enabled"] == (["example"] if committed else []) + assert not (state / "publication.json").exists() + assert not list(target.parent.glob(".previous-*")) + + +@pytest.mark.parametrize("mutation", ["metadata", "target", "staged", "sibling-manifest"]) +def test_staged_publication_refuses_concurrent_input_edits(client, tmp_path, monkeypatch, isolated_python, mutation): + from hermes_cli.runtime_paths import install_state_dir + from pm.store import tree_digest + repo = _current_environment(tmp_path, monkeypatch, []) + home = tmp_path / "home" + target = home / "plugins/example" + target.mkdir(parents=True) + (target / "plugin.yaml").write_text("name: example\n") + (target / "code.py").write_text("old code") + sibling = target.parent / "sibling" + sibling.mkdir() + (sibling / "plugin.yaml").write_text("name: sibling\npython_dependencies: []\n") + (home / "config.yaml").write_text("plugins:\n enabled: [example, sibling]\n") + metadata = target.parent / ".install-metadata.json" + metadata.write_text("{}\n") + staged = tmp_path / "staged" + staged.mkdir() + (staged / "plugin.yaml").write_text("name: example\npython_dependencies: []\n") + (staged / "code.py").write_text("new code") + edited = {"metadata": metadata, "target": target / "code.py", "staged": staged / "code.py", + "sibling-manifest": sibling / "plugin.yaml"}[mutation] + proposed = "name: sibling\nversion: changed\npython_dependencies: []\n" if mutation == "sibling-manifest" else "concurrent edit" + worker_toolchain(client, monkeypatch, isolated_python, + "from pm.packages import Venv\n" + "def apply(*args, **kwargs):\n" + f" Path({str(edited)!r}).write_text({proposed!r})\n return {{}}\n" + "Venv.apply = apply\n") + facts = (install_state_dir(repo) / "facts.json").read_bytes() + with pytest.raises(ValueError, match="changed"): + client.sync_venv(explicit=True, staged_plugin={ + "target": str(target), "staged": str(staged), "target_digest": tree_digest(target), + "old_metadata": {}, "new_metadata": {"example": {"revision": "new"}}, + }) + assert edited.read_text() == proposed + assert (target / "code.py").read_text() == (proposed if mutation == "target" else "old code") + assert (install_state_dir(repo) / "facts.json").read_bytes() == facts + assert not (install_state_dir(repo) / "publication.json").exists() + + +def test_inactive_portable_publication_does_not_inspect_unrelated_dependency_manifests(client, tmp_path, monkeypatch): + from pm.store import tree_digest + _current_environment(tmp_path, monkeypatch, []) + home = tmp_path / "home" + target = home / "plugins/inactive" + target.mkdir(parents=True) + sibling = home / "plugins/sibling" + sibling.mkdir() + (sibling / "plugin.yaml").write_bytes(b"\xff") + (home / "config.yaml").write_text("plugins:\n enabled: [sibling]\n") + staged = tmp_path / "staged" + staged.mkdir() + from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1 + (staged / "plugin.json").write_text(json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "inactive", "version": "1.0.0"})) + client.sync_venv(explicit=True, staged_plugin={ + "target": str(target), "staged": str(staged), "target_digest": tree_digest(target), + "old_metadata": {}, "new_metadata": {"inactive": {"revision": "new"}}, + }) + assert json.loads((target / "plugin.json").read_text())["name"] == "inactive" + assert (sibling / "plugin.yaml").read_bytes() == b"\xff" + + +def test_selection_preserves_yaml11_values_and_quotes_plugin_names(client, tmp_path, monkeypatch): + import hermes_yaml + _current_environment(tmp_path, monkeypatch, []) + home = tmp_path / "home" + config = home / "config.yaml" + config.write_text('feature: yes\nother: no\nlabel: "on"\nplugins: {enabled: []}\n') + before = hermes_yaml.safe_load(config.read_bytes()) + client.sync_venv(explicit=True, selection={"home": str(home), "enabled": ["on", "yes", "no"], "disabled": []}) + after = hermes_yaml.safe_load(config.read_bytes()) + assert {key: after[key] for key in ("feature", "other", "label")} == {key: before[key] for key in ("feature", "other", "label")} + assert set(after["plugins"]["enabled"]) == {"on", "yes", "no"} + assert 'label: "on"' in config.read_text() + + +def test_explicit_publication_keeps_its_intent_through_tool_acquisition(client, tmp_path, monkeypatch, isolated_python): + from pm import paths + from hermes_cli.runtime_paths import selected_venv + project = tmp_path / "project" + project.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: project) + (project / "pyproject.toml").write_text('[project]\nname="explicit-proof"\nversion="1"\nrequires-python=">=3.11"\n[tool.uv]\npackage=false\n') + worker_toolchain(client, monkeypatch, isolated_python) + client.lock_project(project, offline=True, explicit=True) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + worker_toolchain(client, monkeypatch, isolated_python, + "from pm.package import InstallError\ntools = pm._uv._toolchain\n" + "def acquire(*, explicit=False, **kwargs):\n" + " if not explicit: raise InstallError('tools', 'explicit intent was lost')\n" + " return tools(explicit=explicit, **kwargs)\npm._uv._toolchain = acquire\n") + client.sync_venv(explicit=True, selection={"home": str(tmp_path / "home"), "enabled": [], "disabled": []}) + assert (selected_venv(project) / "pyvenv.cfg").is_file() + + +def test_stale_enablement_cannot_replace_a_newer_selection(client, tmp_path, monkeypatch): + from hermes_cli import plugins_cmd as pc + from hermes_cli.plugins_admission import AdmissionRefused + from utils import fast_safe_load + _current_environment(tmp_path, monkeypatch, []) + config = tmp_path / "home/config.yaml" + config.write_text("plugins: {enabled: [], disabled: []}\n") + read = pc._get_enabled_set + def concurrent_commit(): + stale = read() + config.write_text("plugins: {enabled: [first], disabled: []}\n") + return stale + monkeypatch.setattr(pc, "_get_enabled_set", 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"] diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 527ef87b1a..0806c031fa 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -207,7 +207,7 @@ def test_enabled_member_dirs_finds_enabled_dep_plugins(tmp_path, monkeypatch): # newest LAST) — discovery preserves the configured order. monkeypatch.setattr( "pm.plugins_state.enabled_plugins_ordered", - lambda: {plugins: ["legacy-plug", "modern-plug", "plain-plug"]}, + lambda **kwargs: {plugins: ["legacy-plug", "modern-plug", "plain-plug"]}, ) found = ws.enabled_member_dirs() names = [p.name for p in found] @@ -223,7 +223,7 @@ def test_enabled_member_dirs_empty_when_nothing_enabled(tmp_path, monkeypatch): (member / "pyproject.toml").write_text("[project]\n", encoding="utf-8") monkeypatch.setattr( - "pm.plugins_state.enabled_plugins_ordered", lambda: {} + "pm.plugins_state.enabled_plugins_ordered", lambda **kwargs: {} ) assert ws.enabled_member_dirs() == [] diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 5746706d00..0304d3efe6 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -114,6 +114,13 @@ prepared environment inputs. The generated workspace and extended lock remain outside shipped source. Repair copies the recorded workspace and lock, including plugin build inputs, rather than resolving against edited live manifests. +Plugin enablement and staged code updates are submitted as data to the isolated +PM worker. Under the installation lock, it discovers the proposed dependency +union, validates the candidate, and publishes configuration or plugin files and +metadata with the environment selection. It rejects inputs changed during +preparation. A durable journal permits recovery before application imports, +including code-only updates that do not require a new environment. + A failed candidate does not replace the selected environment or silently disable other plugins. If preparation succeeds, a restart can still be required to activate the new environment in a running Hermes process. From 1662c7870244fc0344cd947f99ed242ecbc6e198 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:07:01 -0400 Subject: [PATCH 17/33] Migrate stale sidecar and private uv tests to PM's current interface --- tests/pm/test_workspace_output_encoding.py | 26 ++++++++++++++++++++-- tests/test_pm_bionic.py | 12 +++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/pm/test_workspace_output_encoding.py b/tests/pm/test_workspace_output_encoding.py index 0563f52f4a..7802ee14c0 100644 --- a/tests/pm/test_workspace_output_encoding.py +++ b/tests/pm/test_workspace_output_encoding.py @@ -9,8 +9,9 @@ import sys import pytest +import pm import pm.workspace as ws -from pm.package import InstallError +from pm.package import InstallError, Runner @pytest.fixture @@ -87,14 +88,35 @@ def test_node_sidecar_retains_output_and_exit_status( diagnostic = "🔍 node-gyp: build toolchain unavailable" raw = diagnostic.encode("utf-8") + b"\xff\n" completed = [] + npm_dir = tmp_path / "pm-bin" + npm_dir.mkdir() + npm = npm_dir / ("npm.cmd" if os.name == "nt" else "npm") + npm.write_text("process boundary fixture", encoding="utf-8") + npm.chmod(0o755) + context = Runner("npm", dict(os.environ, PATH=str(npm_dir))) + acquisitions = [] + + def acquire(name, **kwargs): + acquisitions.append((name, kwargs)) + return context def run_npm(cmd, **kwargs): + assert cmd == [str(npm), install_cmd, "--no-audit", "--no-fund"] + assert kwargs["env"] == context.env + assert kwargs["cwd"] == str(tmp_path) + # Keep the real Runner and decoding path; only replace npm's process + # with a Python child that emits controlled bytes and an exit status. result = legacy_locale_child(**{stream: raw}, returncode=returncode, **kwargs) completed.append(result) return result - error = ws.install_node_sidecar(tmp_path, npm_bin=sys.executable, runner=run_npm) + monkeypatch.setattr(pm, "ensure", acquire) + monkeypatch.setattr("pm.package.subprocess.run", run_npm) + error = ws.install_node_sidecar(tmp_path) + assert acquisitions == [("npm", {"explicit": False})] + assert len(completed) == 1 + assert completed[0].returncode == returncode expected = diagnostic + "�" if returncode: assert error == f"npm {install_cmd} exited {returncode}: {expected}" diff --git a/tests/test_pm_bionic.py b/tests/test_pm_bionic.py index 85599a36dd..2e90ee4459 100644 --- a/tests/test_pm_bionic.py +++ b/tests/test_pm_bionic.py @@ -174,9 +174,8 @@ def test_bionic_verify_is_file_evidence(tmp_path: Path, monkeypatch, name): def test_bionic_binary_and_env_contract(tmp_path: Path): - """On bionic, _BionicDebArm.binary() must return the staged deb's main - binary path (file evidence, no exec), so the base Package.env contract - exposes the tool through PATH like every other pm package.""" + """Bionic binaries retain their staged paths, but only on_path packages + expose them in the environment; internal uv stays private to PM.""" from pm.registry import get_package for name in ("uv", "python", "node"): @@ -190,9 +189,12 @@ def test_bionic_binary_and_env_contract(tmp_path: Path): assert binary == main, f"{name}.binary() on bionic: {binary}" env = pkg.env(entry, "linux-arm64-bionic") - assert env.get("PATH") == [str(main.parent)], ( - f"{name}.env() on bionic does not follow the Package.env PATH contract" + expected_path = [str(main.parent)] if pkg.on_path else None + assert env.get("PATH") == expected_path, ( + f"{name}.env() on bionic does not follow its on_path declaration" ) + if pkg.internal: + assert "PATH" not in env, f"internal {name} must not leak into public PATH" def test_stage_only_does_not_record_host_facts(tmp_path, monkeypatch): From eea968ea205a1d4f57b581b2bc86694e7d2a5bfa Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:06:51 -0400 Subject: [PATCH 18/33] Share remote setup state and controls across desktop hosts --- .../settings/connections-registry.test.tsx | 89 +++- .../src/app/settings/connections-registry.tsx | 251 ++-------- .../app/settings/gateway-settings.test.tsx | 174 ++++++- .../src/app/settings/gateway-settings.tsx | 445 +++--------------- .../desktop-install-overlay.test.tsx | 33 ++ .../components/desktop-install-overlay.tsx | 4 +- .../src/components/first-run-remote-form.tsx | 346 -------------- .../src/components/remote-setup/fields.tsx | 152 ++++++ .../src/components/remote-setup/first-run.tsx | 78 +++ .../remote-setup/use-remote-setup.test.tsx | 100 ++++ .../remote-setup/use-remote-setup.ts | 371 +++++++++++++++ 11 files changed, 1099 insertions(+), 944 deletions(-) delete mode 100644 apps/desktop/src/components/first-run-remote-form.tsx create mode 100644 apps/desktop/src/components/remote-setup/fields.tsx create mode 100644 apps/desktop/src/components/remote-setup/first-run.tsx create mode 100644 apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx create mode 100644 apps/desktop/src/components/remote-setup/use-remote-setup.ts diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index 6a5b4007f7..96e6774da4 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopConnectionsRegistry } from '@/global' @@ -69,6 +69,93 @@ afterEach(() => { }) describe('ConnectionsRegistrySection', () => { + it('preserves, replaces and deletes stored headers through plaintext consent without selecting a source', async () => { + const active = $connection.get() + + const withHeaders: DesktopConnectionsRegistry = { + ...registry, + secureTokenStorage: false, + connections: [registry.connections[0], { ...registry.connections[1], headerNames: ['Keep', 'Replace', 'Delete'] }] + } + + list.mockResolvedValueOnce(withHeaders) + save.mockRejectedValueOnce(new Error('plaintext consent required')) + const applyConnectionConfig = vi.fn() + const select = vi.fn() + Object.assign(window.hermesDesktop, { applyConnectionConfig }) + Object.assign(window.hermesDesktop.connections, { select }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })) + const values = screen.getAllByPlaceholderText('Saved — leave blank to keep') + fireEvent.change(values[1], { target: { value: 'new-header-secret' } }) + fireEvent.click(within(screen.getByDisplayValue('Delete').parentElement!).getByRole('button', { name: 'Remove' })) + fireEvent.change(screen.getByPlaceholderText('Existing token ...abc123'), { target: { value: 'new-token' } }) + fireEvent.click(screen.getByText('Save connection')) + await screen.findByText('Store the gateway token in plain text?') + + const expected = { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'http://homelab.lan:9119', + authMode: 'token', + token: 'new-token', + headers: { Keep: null, Replace: 'new-header-secret' } + } + + expect(save).toHaveBeenCalledExactlyOnceWith(expected) + fireEvent.click(screen.getByRole('button', { name: 'Save as plain text' })) + await waitFor(() => expect(save).toHaveBeenLastCalledWith({ ...expected, allowPlainTextToken: true })) + expect(applyConnectionConfig).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + expect($connection.get()).toBe(active) + }) + + it('keeps stale browser sign-in out of a storage-only edit for a different URL', async () => { + const active = $connection.get() + const applyConnectionConfig = vi.fn() + const saveConnectionConfig = vi.fn() + const probeConnectionConfig = vi.fn().mockResolvedValue({ reachable: true, authMode: 'oauth', providers: [] }) + let finishLogin!: (value: { connected: boolean }) => void + + const oauthLoginConnectionConfig = vi.fn().mockReturnValue( + new Promise<{ connected: boolean }>(resolve => { + finishLogin = resolve + }) + ) + + Object.assign(window.hermesDesktop, { + applyConnectionConfig, + saveConnectionConfig, + probeConnectionConfig, + oauthLoginConnectionConfig + }) + render() + fireEvent.click(await screen.findByText('Add connection')) + fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'New gateway' } }) + const url = screen.getByPlaceholderText('http://homelab.lan:9119') + fireEvent.change(url, { target: { value: 'https://a.example' } }) + fireEvent.click(screen.getByRole('button', { name: /^(OAuth|Sign in)$/ })) + fireEvent.click(await screen.findByRole('button', { name: /Sign in with/ })) + await waitFor(() => expect(oauthLoginConnectionConfig).toHaveBeenCalledWith('https://a.example')) + fireEvent.change(url, { target: { value: 'https://b.example' } }) + await act(async () => finishLogin({ connected: true })) + expect(screen.queryByText('Signed in')).toBeNull() + fireEvent.click(screen.getByText('Save connection')) + await waitFor(() => + expect(save).toHaveBeenCalledWith({ + kind: 'remote', + label: 'New gateway', + url: 'https://b.example', + authMode: 'oauth', + headers: {} + }) + ) + expect(applyConnectionConfig).not.toHaveBeenCalled() + expect(saveConnectionConfig).not.toHaveBeenCalled() + expect($connection.get()).toBe(active) + }) + it('refreshes a cached roster immediately after a successful connection test', async () => { _resetFleetRosterForTests() const getAgentRoster = vi.fn().mockResolvedValue({ agents: [], sources: [] }) diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index a6af04357c..af2bf5a190 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -1,12 +1,13 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { RemoteSetupFields } from '@/components/remote-setup/fields' +import { useRemoteSetup } from '@/components/remote-setup/use-remote-setup' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Input } from '@/components/ui/input' import type { DesktopConnectionKind, - DesktopConnectionProbeResult, DesktopConnectionsRegistry, DesktopRegistryConnection, DesktopRegistryConnectionInput @@ -17,23 +18,8 @@ import { connectionMatchesQuery, sortConnectionsForDisplay } from '@/lib/connection-display' -import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' import { triggerHaptic } from '@/lib/haptics' -import { - Check, - Cloud, - Globe, - Loader2, - LogIn, - Monitor, - Pencil, - Plus, - RefreshCw, - SearchIcon, - Terminal, - Trash2 -} from '@/lib/icons' -import { coerceRemoteUrlScheme } from '@/lib/remote-url' +import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Terminal, Trash2 } from '@/lib/icons' import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections' import { refreshFleetRoster } from '@/store/fleet-roster' import { notify, notifyError } from '@/store/notifications' @@ -52,9 +38,6 @@ interface EditorState { id: null | string kind: DesktopConnectionKind label: string - url: string - authMode: 'oauth' | 'token' - token: string host: string keyPath: string // ssh remote profile, hydrated on edit so the duplicate key matches the @@ -73,9 +56,6 @@ function editorFromConnection(conn: DesktopRegistryConnection): EditorState { id: conn.id, kind: conn.kind, label: conn.label, - url: conn.url || '', - authMode: conn.authMode || 'token', - token: '', // Reconstruct the composite the single ssh host field displays. The save // payload sends ONLY this string (never separate user/port), because // normalizeSshConfig gives explicit user/port fields precedence over the @@ -93,9 +73,6 @@ function emptyEditor(kind: DesktopConnectionKind): EditorState { id: null, kind, label: '', - url: '', - authMode: 'token', - token: '', host: '', keyPath: '', remoteProfile: '', @@ -143,7 +120,7 @@ export function sshCompositeKey(composite: string): string { * Returns the existing entry the candidate collides with, or null. */ export function findDuplicateConnection( - editor: Pick, + editor: Pick & { url: string }, connections: DesktopRegistryConnection[] ): DesktopRegistryConnection | null { if (editor.kind === 'local') { @@ -255,14 +232,12 @@ export function ConnectionsRegistrySection() { // Inline duplicate rejection from the save path (dedupe is also enforced in // the main process, so a crafted payload can't slip past the UI check). const [dupeError, setDupeError] = useState(null) - // A gated remote gateway (OAuth, or username/password) never accepts a - // session token: it authenticates with a browser sign-in and keeps the - // session itself. Probe the edited URL so this row can name the provider, - // and remember whether the login round-trip actually completed. - const [authProbe, setAuthProbe] = useState(null) - const [signingIn, setSigningIn] = useState(false) - const [oauthConnected, setOauthConnected] = useState(false) - const probeSeq = useRef(0) + + const remote = useRemoteSetup({ + host: 'registry', + enabled: editor?.kind === 'remote', + onNotice: notify + }) const bridge = window.hermesDesktop?.connections @@ -273,94 +248,6 @@ export function ConnectionsRegistrySection() { setConnectionsRegistry(next) }, []) - const editorUrl = editor?.kind === 'remote' ? coerceRemoteUrlScheme(editor.url) : '' - const editorWantsOauth = editor?.kind === 'remote' && editor.authMode === 'oauth' - const authProviderShape = deriveRemoteAuthProviderShape(authProbe?.providers, t.boot.failure.identityProvider) - - // Probe only while the sign-in row is on screen, and debounce it so typing a - // URL doesn't fire a request per keystroke. Best-effort: a failed probe just - // leaves the generic provider label, it never blocks signing in. - useEffect(() => { - if (!editorWantsOauth || !editorUrl || !window.hermesDesktop?.probeConnectionConfig) { - setAuthProbe(null) - - return - } - - const seq = ++probeSeq.current - // Staleness is covered by probeSeq, but not unmount: a probe resolving - // after the editor closes would still call setAuthProbe on an unmounted - // component. Harmless in React 18, still worth not doing. - let cancelled = false - - const timer = setTimeout(() => { - window.hermesDesktop - .probeConnectionConfig(editorUrl) - .then(result => { - if (!cancelled && seq === probeSeq.current) { - setAuthProbe(result) - } - }) - .catch(() => { - if (!cancelled && seq === probeSeq.current) { - setAuthProbe(null) - } - }) - }, 400) - - return () => { - cancelled = true - clearTimeout(timer) - } - }, [editorUrl, editorWantsOauth]) - - // The session is scoped to an origin, so pointing the editor at a different - // URL invalidates the "signed in" state this row is reporting. Flipping the - // auth mode invalidates it too: a saved row edited token -> oauth must not - // present a stale "Signed in" pill from an earlier oauth stint. - useEffect(() => { - setOauthConnected(false) - }, [editorUrl, editorWantsOauth]) - - // Open the gateway's own login window and let the main process keep whatever - // it mints (native PKCE bearer tokens, or the legacy session cookies). This - // is the same IPC the first-run form and the gateway panel use — the - // registry editor simply had no affordance to reach it. - const signInOauth = useCallback(async () => { - if (!editorUrl) { - notify({ kind: 'warning', title: t.settings.gateway.authTitle, message: t.settings.gateway.enterUrlFirst }) - - return - } - - setSigningIn(true) - - try { - const result = await window.hermesDesktop.oauthLoginConnectionConfig(editorUrl) - - setOauthConnected(Boolean(result.connected)) - - if (result.connected) { - notify({ - title: t.settings.gateway.signedIn, - message: t.settings.gateway.connectedTo(authProviderShape.providerLabel) - }) - } else { - notify({ - kind: 'warning', - title: t.boot.failure.signInIncompleteTitle, - message: result?.error - ? `${t.boot.failure.signInIncompleteMessage}: ${result.error}` - : t.boot.failure.signInIncompleteMessage - }) - } - } catch (err) { - notifyError(err, t.settings.gateway.signInFailed) - } finally { - setSigningIn(false) - } - }, [authProviderShape.providerLabel, editorUrl, t]) - const load = useCallback(async () => { if (!bridge) { setLoading(false) @@ -383,13 +270,19 @@ export function ConnectionsRegistrySection() { void load() }, [load]) - const openEditor = (next: EditorState | null) => { + const openEditor = (next: EditorState | null, saved?: DesktopRegistryConnection): void => { setDupeError(null) + remote.reset({ + url: saved?.url || '', + authMode: saved?.authMode || 'token', + tokenSet: saved?.tokenSet ?? false, + tokenPreview: saved?.tokenPreview ?? null + }) setEditor(next) } const save = useCallback( - async (allowPlainTextToken = false) => { + async (allowPlainTextToken: boolean = false): Promise => { if (!bridge || !editor) { return } @@ -397,7 +290,7 @@ export function ConnectionsRegistrySection() { // Duplicate prevention lives in the save path (not just a disabled // button): reject a candidate that collides with an existing entry with // an inline error before anything crosses the IPC boundary. - const dupe = findDuplicateConnection(editor, registry?.connections ?? []) + const dupe = findDuplicateConnection({ ...editor, url: remote.credentials.url }, registry?.connections ?? []) if (dupe) { setDupeError( @@ -425,11 +318,11 @@ export function ConnectionsRegistrySection() { } if (editor.kind === 'remote' || editor.kind === 'cloud') { - payload.url = editor.url - payload.authMode = editor.authMode + payload.url = remote.payload.remoteUrl + payload.authMode = remote.credentials.authMode - if (editor.token.trim()) { - payload.token = editor.token.trim() + if (remote.payload.remoteToken) { + payload.token = remote.payload.remoteToken } if (allowPlainTextToken) { @@ -467,8 +360,8 @@ export function ConnectionsRegistrySection() { !allowPlainTextToken && registry?.secureTokenStorage === false && editor.kind === 'remote' && - editor.authMode === 'token' && - editor.token.trim() + remote.credentials.authMode === 'token' && + remote.credentials.token.trim() ) { setPlainTextConfirm(true) @@ -480,7 +373,16 @@ export function ConnectionsRegistrySection() { setSaving(false) } }, - [bridge, editor, publishRegistry, registry?.connections, registry?.secureTokenStorage, s] + [ + bridge, + editor, + remote.credentials, + remote.payload, + publishRegistry, + registry?.connections, + registry?.secureTokenStorage, + s + ] ) const remove = useCallback(async () => { @@ -722,7 +624,7 @@ export function ConnectionsRegistrySection() { <> - ))} - - } - title={t.settings.gateway.authTitle} - /> - {editor.authMode === 'token' && ( - setEditor({ ...editor, token: e.target.value })} - placeholder={t.settings.gateway.pasteSessionToken} - type="password" - value={editor.token} - /> - } - description={t.settings.gateway.tokenDesc} - title={t.settings.gateway.tokenTitle} - /> - )} - {editor.authMode === 'oauth' && ( - - {t.settings.gateway.signedIn} - - ) : ( - - ) - } - description={ - oauthConnected - ? authProviderShape.isPassword - ? t.settings.gateway.authSignedInPassword - : t.settings.gateway.authSignedInOauth - : authProviderShape.isPassword - ? t.settings.gateway.authNeedsPassword - : t.settings.gateway.authNeedsOauth(authProviderShape.providerLabel) - } - title={t.settings.gateway.authTitle} - /> - )} - - )} - {(editor.kind === 'remote' || editor.kind === 'cloud') && (
diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx index a42a6ed77f..31d4fe470f 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Collect the component graph before the behavioral test deadline starts. @@ -57,6 +57,178 @@ afterEach(() => { }) describe('GatewaySettings', () => { + it('releases a pending save after a late probe invalidates its response', async () => { + const saved = { ...localConnection, mode: 'remote', remoteUrl: 'https://a.example', remoteTokenSet: true } + getConnectionConfig.mockResolvedValue(saved) + let finishSave!: (value: typeof saved) => void + let finishProbe!: (value: { reachable: boolean; authMode: string; providers: never[] }) => void + saveConnectionConfig.mockReturnValueOnce( + new Promise(resolve => { + finishSave = resolve + }) + ) + + const probeConnectionConfig = vi.fn( + () => + new Promise<{ reachable: boolean; authMode: string; providers: never[] }>(resolve => { + finishProbe = resolve + }) + ) + + Object.assign(window.hermesDesktop, { probeConnectionConfig }) + render() + const saveButton = (await screen.findByRole('button', { name: 'Save for next restart' })) as HTMLButtonElement + await waitFor(() => expect(probeConnectionConfig).toHaveBeenCalledWith('https://a.example')) + fireEvent.click(saveButton) + expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({ + mode: 'remote', + remoteUrl: 'https://a.example', + remoteAuthMode: 'token', + remoteToken: undefined + }) + expect(saveButton.disabled).toBe(true) + await act(async () => finishProbe({ reachable: true, authMode: 'oauth', providers: [] })) + await act(async () => finishSave(saved)) + expect(saveButton.disabled).toBe(false) + expect(screen.getByRole('button', { name: /Sign in with/ })).toBeTruthy() + expect(screen.queryByPlaceholderText('Existing token saved')).toBeNull() + }) + + it('pre-saves OAuth before login and applies the resolved auth mode without requiring a test', async () => { + getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://login.example' }) + let finishSave!: () => void + saveConnectionConfig.mockReturnValueOnce( + new Promise(resolve => { + finishSave = resolve + }) + ) + const oauthLoginConnectionConfig = vi.fn().mockResolvedValue({ connected: true }) + const applyConnectionConfig = vi.fn().mockResolvedValue(localConnection) + const testConnectionConfig = vi.fn() + Object.assign(window.hermesDesktop, { + oauthLoginConnectionConfig, + applyConnectionConfig, + testConnectionConfig, + probeConnectionConfig: vi.fn().mockResolvedValue({ + reachable: true, + authMode: 'oauth', + providers: [{ name: 'password', displayName: 'Username & Password', supportsPassword: true }] + }) + }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Sign in' })) + expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({ + mode: 'remote', + remoteAuthMode: 'oauth', + remoteUrl: 'https://login.example' + }) + expect(oauthLoginConnectionConfig).not.toHaveBeenCalled() + await act(async () => finishSave()) + await screen.findByText('Signed in') + expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://login.example') + fireEvent.click(screen.getByRole('button', { name: 'Save and reconnect' })) + await waitFor(() => + expect(applyConnectionConfig).toHaveBeenCalledExactlyOnceWith({ + mode: 'remote', + remoteAuthMode: 'oauth', + remoteUrl: 'https://login.example', + remoteToken: undefined + }) + ) + expect(testConnectionConfig).not.toHaveBeenCalled() + }) + + it('keeps a saved token when blank and requires consent before replacing it in plaintext', async () => { + const saved = { + ...localConnection, + mode: 'remote', + remoteUrl: 'https://a.example', + remoteTokenSet: true, + remoteTokenPreview: 'saved-preview', + secureTokenStorage: false, + remoteTokenPlainText: true + } + + getConnectionConfig.mockResolvedValue(saved) + saveConnectionConfig.mockResolvedValue(saved) + let finishSave!: (value: typeof saved) => void + saveConnectionConfig.mockReturnValueOnce( + new Promise(resolve => { + finishSave = resolve + }) + ) + Object.assign(window.hermesDesktop, { + probeConnectionConfig: vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] }) + }) + render() + await screen.findByPlaceholderText('Existing token saved-preview') + fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' })) + await waitFor(() => + expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({ + mode: 'remote', + remoteUrl: 'https://a.example', + remoteAuthMode: 'token', + remoteToken: undefined + }) + ) + // Flush the save's reset and probe effects before acquiring the replacement field. + await act(async () => finishSave(saved)) + const tokenInput = await screen.findByPlaceholderText('Existing token saved-preview') + expect(tokenInput.isConnected, 'saved credential control must survive the refresh probe').toBe(true) + fireEvent.change(tokenInput, { target: { value: 'replacement' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' })) + await screen.findByText('Store the gateway token in plain text?') + expect(saveConnectionConfig).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Save as plain text' })) + await waitFor(() => + expect(saveConnectionConfig).toHaveBeenLastCalledWith({ + mode: 'remote', + remoteUrl: 'https://a.example', + remoteAuthMode: 'token', + remoteToken: 'replacement', + allowPlainTextToken: true + }) + ) + }) + + it('discards an old token test while saving the current credential-ready payload', async () => { + getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://a.example' }) + const probeConnectionConfig = vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] }) + let finishTest!: (value: { ok: boolean; baseUrl: string }) => void + + const testConnectionConfig = vi.fn().mockReturnValue( + new Promise<{ ok: boolean; baseUrl: string }>(resolve => { + finishTest = resolve + }) + ) + + Object.assign(window.hermesDesktop, { probeConnectionConfig, testConnectionConfig }) + render() + const token = await screen.findByPlaceholderText('Paste session token') + fireEvent.change(token, { target: { value: 'old-token' } }) + fireEvent.click(screen.getByRole('button', { name: 'Test remote' })) + expect(testConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + remoteUrl: 'https://a.example', + remoteAuthMode: 'token', + remoteToken: 'old-token' + }) + fireEvent.change(token, { target: { value: 'new-token' } }) + await act(async () => finishTest({ ok: true, baseUrl: 'https://a.example' })) + expect(screen.queryByText('Connected to https://a.example')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' })) + await waitFor(() => + expect(saveConnectionConfig).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'remote', + remoteUrl: 'https://a.example', + remoteAuthMode: 'token', + remoteToken: 'new-token' + }) + ) + ) + }) + it('keeps saved Cloud instances usable without discovery and marks the live source, not the default', async () => { getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud', remoteUrl: 'https://a.example' }) registry.value = { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index ac5a2b43d2..5409a87c41 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -1,12 +1,14 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { RemoteSetupFields } from '@/components/remote-setup/fields' +import { useRemoteSetup } from '@/components/remote-setup/use-remote-setup' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' -import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' +import type { DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionConfigInput } from '@/global' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' import { @@ -41,7 +43,6 @@ import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' type Mode = 'local' | 'remote' | 'cloud' | 'ssh' type AuthMode = 'oauth' | 'token' -type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' // Hermes Cloud discovery lifecycle for the cloud-mode panel. type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error' @@ -164,15 +165,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) - const [signingIn, setSigningIn] = useState(false) const [state, setState] = useState(EMPTY_STATE) - const [remoteToken, setRemoteToken] = useState('') + + const remote = useRemoteSetup({ + host: 'settings', + enabled: !loading && state.mode === 'remote', + beforeOAuthLogin: async (payload: DesktopConnectionConfigInput): Promise => { + await window.hermesDesktop.saveConnectionConfig(payload) + }, + onNotice: notify + }) + const [lastTest, setLastTest] = useState(null) const [sshHostSuggestions, setSshHostSuggestions] = useState([]) const [sshCustomHost, setSshCustomHost] = useState(false) const sshResolveSeq = useRef(0) const sshTestSeq = useRef(0) const saveSeq = useRef(0) + const saveOwner = useRef(null) const signingSeq = useRef(0) const cloudConnectSeq = useRef(0) const contextSeq = useRef(0) @@ -224,10 +234,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } } - const acceptSavedConfig = (config: GatewaySettingsState) => { + const acceptSavedConfig = (config: GatewaySettingsState): void => { const normalized = normalizeGatewaySettingsState(config) setState(normalized) + remote.reset({ + url: normalized.remoteUrl, + authMode: normalized.remoteAuthMode, + oauthConnected: normalized.remoteOauthConnected, + tokenSet: normalized.remoteTokenSet, + tokenPreview: normalized.remoteTokenPreview + }) } // When set, the plain-text opt-in dialog is open; `apply` remembers whether @@ -261,13 +278,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudOrgState(value) } - // Auth-mode probe: as the user types a remote URL we ask the gateway (via - // its public /api/status) whether it gates with OAuth or a static session - // token, so we can show the right control (login button vs token box). - const [probeStatus, setProbeStatus] = useState('idle') - const [probe, setProbe] = useState(null) - const probeSeq = useRef(0) - useEffect(() => { let cancelled = false const desktop = window.hermesDesktop @@ -300,12 +310,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable }, []) - // Debounced probe of the entered remote URL. Only runs in remote mode with a - // syntactically plausible URL. The probe result drives whether we render the - // OAuth login button or the session-token entry box. The effective auth mode - // prefers a fresh probe result over the saved value. - const trimmedUrl = coerceRemoteUrlScheme(state.remoteUrl) - const savedAgent = (agent: DesktopCloudAgent) => registry?.connections.find( connection => @@ -330,105 +334,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } } - useEffect(() => { - if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { - setProbeStatus('idle') - setProbe(null) - - return - } - - const desktop = window.hermesDesktop - - if (!desktop?.probeConnectionConfig) { - return - } - - const seq = ++probeSeq.current - setProbeStatus('probing') - - const timer = setTimeout(() => { - desktop - .probeConnectionConfig(trimmedUrl) - .then(result => { - if (seq !== probeSeq.current) { - return - } - - setProbe(result) - setProbeStatus(result.reachable ? 'done' : 'error') - }) - .catch(() => { - if (seq !== probeSeq.current) { - return - } - - setProbe(null) - setProbeStatus('error') - }) - }, 500) - - return () => clearTimeout(timer) - }, [state.mode, trimmedUrl]) - - // Effective auth mode: a reachable probe wins; otherwise fall back to the - // saved config's mode so a re-open of settings doesn't flicker. - const authMode: AuthMode = useMemo(() => { - if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') { - return probe.authMode - } - - return state.remoteAuthMode - }, [probe, probeStatus, state.remoteAuthMode]) - - // Whether we actually KNOW how this gateway authenticates yet. Until we do, - // neither the OAuth button nor the session-token box should render — - // `authMode` defaults to 'token', so without this gate the token box flashes - // for every gateway (including OAuth ones) during the idle/probing window - // before the first probe lands. The scheme is known when either: - // * the live probe finished (probeStatus 'done'), or - // * we're idle but showing a previously-saved remote config (re-opening - // settings for a gateway already signed-in or with a saved token), so - // its control appears immediately with no flicker. - // While probing (or after a probe error), the scheme is unknown and we show - // the probe status row instead of a control. - const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected - - const authResolved = useMemo(() => { - if (probeStatus === 'done') { - return true - } - - return probeStatus === 'idle' && hasSavedRemote - }, [probeStatus, hasSavedRemote]) - - const providerLabel = useMemo(() => { - const providers: DesktopAuthProvider[] = probe?.providers ?? [] - - if (providers.length === 1) { - return providers[0].displayName || providers[0].name - } - - if (providers.length > 1) { - return providers.map(p => p.displayName || p.name).join(' / ') - } - - return t.boot.failure.identityProvider - }, [probe, t.boot.failure.identityProvider]) - - // A username/password gateway authenticates through a credential form on the - // gateway's /login page (POST /auth/password-login) rather than an OAuth - // redirect. Everything downstream — the session cookie, the ws-ticket mint, - // the persistent partition — is identical, so the desktop drives it through - // the same sign-in window; only the button copy changes. We treat the - // gateway as password-style only when EVERY advertised provider supports - // password, so a mixed deployment keeps the generic OAuth copy. - const isPasswordProvider = useMemo(() => { - const providers: DesktopAuthProvider[] = probe?.providers ?? [] - - return providers.length > 0 && providers.every(p => p.supportsPassword) - }, [probe]) - useEffect(() => { // One-directional: a saved host that isn't in the suggestions must render // the free-text input (rehydration). Never force custom OFF here — that @@ -472,6 +377,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setLastTest(null) }, [ state.mode, + remote.credentials.url, + remote.credentials.token, + remote.credentials.authMode, state.sshHost, state.sshUser, state.sshPort, @@ -480,33 +388,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { state.sshRemoteProfile ]) - const oauthConnected = state.remoteOauthConnected - - const canUseRemote = useMemo(() => { - if (!trimmedUrl) { - return false - } - - if (authMode === 'oauth') { - return oauthConnected - } - - return Boolean(remoteToken.trim()) || state.remoteTokenSet - }, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl]) - - const payload = (allowPlainTextToken?: boolean) => ({ - mode: state.mode, - remoteAuthMode: authMode, - remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl, - sshHost: state.sshHost.trim(), - sshUser: state.sshUser.trim() || undefined, - sshPort: state.sshPort, - sshKeyPath: state.sshKeyPath.trim() || undefined, - sshRemoteHermesPath: state.sshRemoteHermesPath.trim(), - // Preserve an intentional blank so an existing remote-profile mapping can - // be cleared instead of being mistaken for an omitted field. - sshRemoteProfile: state.sshRemoteProfile.trim(), + const payload = (allowPlainTextToken?: boolean): DesktopConnectionConfigInput => ({ + ...(state.mode === 'remote' + ? remote.payload + : { + mode: state.mode, + remoteAuthMode: state.remoteAuthMode, + remoteUrl: coerceRemoteUrlScheme(state.remoteUrl), + sshHost: state.sshHost.trim(), + sshUser: state.sshUser.trim() || undefined, + sshPort: state.sshPort, + sshKeyPath: state.sshKeyPath.trim() || undefined, + sshRemoteHermesPath: state.sshRemoteHermesPath.trim(), + // A blank clears an existing remote-profile mapping. + sshRemoteProfile: state.sshRemoteProfile.trim() + }), ...(allowPlainTextToken ? { allowPlainTextToken: true } : {}) }) @@ -515,13 +411,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // and this machine has no OS keyring (safeStorage unavailable). In that case // we must get an explicit opt-in before persisting. const wouldPersistPlainTextToken = - (state.mode === 'remote' || state.mode === 'cloud') && - authMode !== 'oauth' && - Boolean(remoteToken.trim()) && + state.mode === 'remote' && + remote.credentials.authMode === 'token' && + Boolean(remote.credentials.token.trim()) && state.secureTokenStorage === false - const performSave = async (apply: boolean, allowPlainTextToken: boolean) => { + const performSave = async (apply: boolean, allowPlainTextToken: boolean): Promise => { const seq = ++saveSeq.current + saveOwner.current = seq setSaving(true) try { @@ -534,7 +431,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } acceptSavedConfig(next) - setRemoteToken('') notify({ kind: 'success', title: apply ? g.restartingTitle : g.savedTitle, @@ -574,18 +470,20 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { notifyError(err, apply ? g.applyFailed : g.saveFailed) } } finally { - if (seq === saveSeq.current) { + // A stale response cannot replace the draft, but its request must release busy state. + if (seq === saveOwner.current) { + saveOwner.current = null setSaving(false) } } } - const save = async (apply: boolean) => { - if (state.mode === 'remote' && !canUseRemote) { + const save = async (apply: boolean): Promise => { + if (state.mode === 'remote' && !remote.canCommit) { notify({ kind: 'warning', title: g.incompleteTitle, - message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken + message: remote.credentials.authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken }) return @@ -601,94 +499,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { await performSave(apply, false) } - // OAuth sign-in: persist the URL + oauth mode first (so the saved config has - // the URL the login window needs), then open the gateway login window and - // refresh the connection status from the saved config once it completes. - const signIn = async () => { - const seq = ++signingSeq.current - - if (!trimmedUrl) { - notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst }) - - return - } - - setSigningIn(true) - - try { - // Save (don't apply/restart) so the login window has a URL to use and the - // oauth mode is persisted, without yet flipping the live connection. - const saved = await window.hermesDesktop.saveConnectionConfig({ - mode: state.mode, - remoteAuthMode: 'oauth', - remoteUrl: trimmedUrl - }) - - if (seq !== signingSeq.current) { - return - } - - acceptSavedConfig(saved) - - const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) - - if (seq !== signingSeq.current) { - return - } - - if (result.connected) { - const refreshed = await window.hermesDesktop.getConnectionConfig(null) - acceptSavedConfig(refreshed) - notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) }) - } else { - notify({ - kind: 'warning', - title: t.boot.failure.signInIncompleteTitle, - message: result?.error - ? `${t.boot.failure.signInIncompleteMessage}: ${result.error}` - : t.boot.failure.signInIncompleteMessage - }) - } - } catch (err) { - if (seq === signingSeq.current) { - notifyError(err, g.signInFailed) - } - } finally { - if (seq === signingSeq.current) { - setSigningIn(false) - } - } - } - - const signOut = async () => { - if (!trimmedUrl) { - return - } - - const seq = ++signingSeq.current - setSigningIn(true) - - try { - await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl) - const refreshed = await window.hermesDesktop.getConnectionConfig(null) - - if (seq !== signingSeq.current) { - return - } - - acceptSavedConfig(refreshed) - notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) - } catch (err) { - if (seq === signingSeq.current) { - notifyError(err, g.signOutFailed) - } - } finally { - if (seq === signingSeq.current) { - setSigningIn(false) - } - } - } - // --- Hermes Cloud handlers --- // Pull the discovered agent list over the shared portal session. Tolerant of @@ -1054,48 +864,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } } - const testRemote = async () => { - const seq = ++sshTestSeq.current - - if (!canUseRemote) { - notify({ - kind: 'warning', - title: g.incompleteTitle, - message: authMode === 'oauth' ? g.incompleteSignInTest : g.incompleteTokenTest - }) - - return - } - - setTesting(true) - setLastTest(null) - - try { - const result = await window.hermesDesktop.testConnectionConfig({ - mode: 'remote', - remoteAuthMode: authMode, - remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl - }) - - if (seq !== sshTestSeq.current) { - return - } - - const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined) - setLastTest(message) - notify({ kind: 'success', title: g.reachableTitle, message }) - } catch (err) { - if (seq === sshTestSeq.current) { - notifyError(err, g.testFailed) - } - } finally { - if (seq === sshTestSeq.current) { - setTesting(false) - } - } - } - if (loading) { return ( - setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} - /> - } - description={g.remoteUrlDesc} - title={g.remoteUrlTitle} - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( -
- - {g.probing} +
+ + {remote.credentials.authMode === 'token' && state.remoteTokenPlainText ? ( +
+
{g.plainTextStoredTitle}
+
{g.plainTextStoredDesc}
) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( -
- - {g.probeError} -
- ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( - - - {g.signedIn} - - -
- ) : ( - - ) - } - description={ - oauthConnected - ? isPasswordProvider - ? g.authSignedInPassword - : g.authSignedInOauth - : isPasswordProvider - ? g.authNeedsPassword - : g.authNeedsOauth(providerLabel) - } - title={g.authTitle} - /> - ) : null} - - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( - <> - setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet - ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) - : g.pasteSessionToken - } - type="password" - value={remoteToken} - /> - } - description={g.tokenDesc} - title={g.tokenTitle} - /> - - {/* The saved token is on disk in plain text (no OS keyring). Same - banner idiom as envOverride so it reads as a real warning. */} - {state.remoteTokenPlainText ? ( -
- -
-
{g.plainTextStoredTitle}
-
{g.plainTextStoredDesc}
-
-
- ) : null} - - ) : null}
) : null} @@ -1575,12 +1252,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {state.mode === 'remote' ? ( ) : state.mode === 'ssh' ? ( diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx index 51165480c4..ba98301881 100644 --- a/apps/desktop/src/components/desktop-install-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -518,6 +518,39 @@ describe('DesktopInstallOverlay first-run setup', () => { }) }) + it('does not authorize a new URL with an old login result or save before Apply', async () => { + const desktop = installDesktopMock(bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/tmp/hermes', local: 'none', bundled: false } + })) + + const saveConnectionConfig = vi.fn() + Object.assign(desktop, { saveConnectionConfig }) + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'oauth', baseUrl: 'https://a.example', reachable: true, providers: [], error: null, version: null + }) + let finishLogin!: (value: { connected: boolean }) => void + desktop.oauthLoginConnectionConfig.mockReturnValueOnce(new Promise<{ connected: boolean }>(resolve => { + finishLogin = resolve + })) + render() + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + const url = screen.getByPlaceholderText('https://gateway.example.com/hermes') + fireEvent.change(url, { target: { value: 'https://a.example' } }) + fireEvent.click(await screen.findByRole('button', { name: /Sign in with/ })) + await waitFor(() => expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://a.example')) + fireEvent.change(url, { target: { value: 'https://b.example' } }) + await waitFor(() => expect(desktop.probeConnectionConfig).toHaveBeenCalledWith('https://b.example')) + await act(async () => finishLogin({ connected: true })) + fireEvent.click(screen.getByText('Test connection')) + expect(desktop.testConnectionConfig).not.toHaveBeenCalled() + expect(saveConnectionConfig).not.toHaveBeenCalled() + expect(desktop.applyConnectionConfig).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('Back')) + fireEvent.click(await screen.findByText('Install Hermes locally')) + expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1) + expect(saveConnectionConfig).not.toHaveBeenCalled() + }) + it('offers remote connection from the unsupported packaged install screen', async () => { const desktop = installDesktopMock( bootstrapState({ diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 71026701d0..8cfb83d359 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -20,7 +20,7 @@ import { capitalize } from '@/lib/text' import { cn } from '@/lib/utils' import { localCardPresentation } from './desktop-install-local-card' -import { FirstRunRemoteForm } from './first-run-remote-form' +import { FirstRunRemoteSetup } from './remote-setup/first-run' /** * DesktopInstallOverlay @@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP } if (remoteOpen) { - return setRemoteOpen(false)} /> + return setRemoteOpen(false)} /> } if (state.setupChoice) { diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx deleted file mode 100644 index 139f0bd2a9..0000000000 --- a/apps/desktop/src/components/first-run-remote-form.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -import { BrandMark } from '@/components/brand-mark' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import type { DesktopConnectionProbeResult } from '@/global' -import { useI18n } from '@/i18n' -import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' -import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons' -import { coerceRemoteUrlScheme } from '@/lib/remote-url' - -type AuthMode = 'oauth' | 'token' -type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' - -interface FirstRunRemoteFormProps { - onBack: () => void -} - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err || 'Unknown error') -} - -export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) { - const { t } = useI18n() - const copy = t.install - const [remoteUrl, setRemoteUrl] = useState('') - const [remoteToken, setRemoteToken] = useState('') - const [probeStatus, setProbeStatus] = useState('idle') - const [probe, setProbe] = useState(null) - const [oauthConnected, setOauthConnected] = useState(false) - const [signingIn, setSigningIn] = useState(false) - const [testing, setTesting] = useState(false) - const [applying, setApplying] = useState(false) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) - const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState(null) - const probeSeq = useRef(0) - const testSeq = useRef(0) - - const trimmedUrl = coerceRemoteUrlScheme(remoteUrl) - - const invalidateTest = useCallback(() => { - testSeq.current += 1 - setTesting(false) - setError(null) - setSuccess(null) - setLastTestedPayloadKey(null) - }, []) - - useEffect(() => { - const seq = ++probeSeq.current - - if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { - setProbeStatus('idle') - setProbe(null) - setOauthConnected(false) - - return - } - - const desktop = window.hermesDesktop - - if (!desktop?.probeConnectionConfig) { - return - } - - setProbeStatus('probing') - - const timer = window.setTimeout(() => { - desktop - .probeConnectionConfig(trimmedUrl) - .then(result => { - if (seq !== probeSeq.current) { - return - } - - invalidateTest() - setProbe(result) - setProbeStatus(result.reachable ? 'done' : 'error') - - if (result.reachable && result.authMode !== 'oauth') { - setOauthConnected(false) - } - }) - .catch(err => { - if (seq !== probeSeq.current) { - return - } - - setProbe(null) - setProbeStatus('error') - setError(errorMessage(err)) - }) - }, 500) - - return () => window.clearTimeout(timer) - }, [invalidateTest, trimmedUrl]) - - const authMode: AuthMode = probeStatus === 'done' && probe?.authMode === 'oauth' ? 'oauth' : 'token' - const authResolved = probeStatus === 'done' && probe?.authMode !== 'unknown' - const authProviderShape = deriveRemoteAuthProviderShape(probe?.providers, copy.identityProvider) - const { isPassword: isPasswordProvider, providerLabel } = authProviderShape - const canRetryProbe = Boolean(trimmedUrl && probeStatus === 'error') - - const canTest = Boolean( - trimmedUrl && (canRetryProbe || (authResolved && (authMode === 'oauth' ? oauthConnected : remoteToken.trim()))) - ) - - const payload = () => ({ - mode: 'remote' as const, - remoteAuthMode: authMode, - remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl - }) - - const currentPayloadKey = JSON.stringify(payload()) - const payloadKeyRef = useRef(currentPayloadKey) - payloadKeyRef.current = currentPayloadKey - const canApply = lastTestedPayloadKey === currentPayloadKey - - const signIn = async () => { - if (!trimmedUrl) { - setError(copy.enterUrlFirst) - - return - } - - setSigningIn(true) - setError(null) - - try { - // Unlike Settings, first-run intentionally does not pre-save remote mode: - // backing out must still allow local install without leaving a remote - // connection selected. The login IPC accepts the raw URL and stores only - // its OAuth cookies; config is persisted once the user applies. - const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) - invalidateTest() - setOauthConnected(Boolean(result.connected)) - - if (!result.connected) { - setError(result.error ? `${copy.signInIncomplete}: ${result.error}` : copy.signInIncomplete) - } - } catch (err) { - setError(errorMessage(err)) - } finally { - setSigningIn(false) - } - } - - const testRemote = async () => { - if (!canTest) { - setError(authMode === 'oauth' ? copy.incompleteSignInTest : copy.incompleteTokenTest) - - return - } - - const seq = ++testSeq.current - const testedPayload = payload() - const testedPayloadKey = JSON.stringify(testedPayload) - - setTesting(true) - setError(null) - setSuccess(null) - setLastTestedPayloadKey(null) - - try { - if (!authResolved) { - const result = await window.hermesDesktop.probeConnectionConfig(trimmedUrl) - - if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) { - return - } - - setProbe(result) - setProbeStatus(result.reachable ? 'done' : 'error') - setError(result.reachable && result.authMode !== 'unknown' ? null : result.error || copy.probeError) - - return - } - - const result = await window.hermesDesktop.testConnectionConfig(testedPayload) - - if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) { - return - } - - setSuccess(copy.testSucceeded(result.baseUrl || trimmedUrl, result.version ?? undefined)) - setLastTestedPayloadKey(testedPayloadKey) - } catch (err) { - if (seq === testSeq.current && testedPayloadKey === payloadKeyRef.current) { - setError(errorMessage(err)) - } - } finally { - if (seq === testSeq.current) { - setTesting(false) - } - } - } - - const applyRemote = async () => { - if (!canApply) { - return - } - - const testedPayload = payload() - - setApplying(true) - setError(null) - let applied = false - - try { - await window.hermesDesktop.applyConnectionConfig(testedPayload) - applied = true - } catch (err) { - setError(errorMessage(err)) - } finally { - setApplying(false) - } - - if (applied) { - onBack() - } - } - - return ( -
-
-
- -
-

{copy.remoteSetupTitle}

-

{copy.remoteSetupDesc}

-
-
- -
- - - {probeStatus === 'probing' ? ( -
- - {copy.probing} -
- ) : null} - - {probeStatus === 'error' ? ( -
- - {probe?.error || copy.probeError} -
- ) : null} - - {authResolved && authMode === 'oauth' ? ( -
-
-
-
{copy.authTitle}
-

- {oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)} -

-
- {oauthConnected ? ( -
- - {copy.connected} -
- ) : ( - - )} -
-
- ) : null} - - {authResolved && authMode === 'token' ? ( - - ) : null} - - {error ? ( -
- - {error} -
- ) : null} - - {success ? ( -
- - {success} -
- ) : null} -
- -
- -
- - -
-
-
-
- ) -} diff --git a/apps/desktop/src/components/remote-setup/fields.tsx b/apps/desktop/src/components/remote-setup/fields.tsx new file mode 100644 index 0000000000..9ae00f239d --- /dev/null +++ b/apps/desktop/src/components/remote-setup/fields.tsx @@ -0,0 +1,152 @@ +import type { ReactElement, ReactNode } from 'react' + +import { ListRow, Pill } from '@/app/settings/primitives' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useI18n } from '@/i18n' +import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons' + +import type { RemoteSetup } from './use-remote-setup' + +interface FieldProps { + stacked: boolean + title: string + description?: string + children: ReactNode +} + +function Field({ stacked, title, description, children }: FieldProps): ReactElement { + return stacked ? ( +
+ {title} + {children} + {description ? {description} : null} +
+ ) : ( + + ) +} + +interface RemoteSetupFieldsProps { + setup: RemoteSetup + disabled?: boolean + urlOnly?: boolean + onUrlChange?: () => void +} + +export function RemoteSetupFields({ + setup, + disabled = false, + urlOnly = false, + onUrlChange +}: RemoteSetupFieldsProps): ReactElement { + const { t } = useI18n() + const g = t.settings.gateway + const firstRun = setup.host === 'first-run' + const registry = setup.host === 'registry' + const copy = firstRun ? t.install : g + const { credentials, isPassword, providerLabel } = setup + const urlTitle = registry ? t.settings.connections.urlTitle : copy.remoteUrlTitle + + const authDescription = firstRun + ? credentials.oauthConnected + ? t.install.authSignedIn + : t.install.authNeedsOauth(providerLabel) + : credentials.oauthConnected + ? isPassword + ? g.authSignedInPassword + : g.authSignedInOauth + : isPassword + ? g.authNeedsPassword + : g.authNeedsOauth(providerLabel) + + return ( +
+ + { + setup.setUrl(event.target.value) + onUrlChange?.() + }} + placeholder={registry ? 'http://homelab.lan:9119' : t.install.remoteUrlPlaceholder} + value={credentials.url} + /> + + {!registry && setup.probeStatus === 'probing' ? ( +
+ + {copy.probing} +
+ ) : null} + {!registry && setup.probeStatus === 'error' ? ( +
+ + {copy.probeError} +
+ ) : null} + {registry && !urlOnly ? ( + +
+ {(['token', 'oauth'] as const).map(mode => ( + + ))} +
+
+ ) : null} + {!urlOnly && setup.authResolved && credentials.authMode === 'oauth' ? ( + + {credentials.oauthConnected ? ( +
+ + + {firstRun ? t.install.connected : g.signedIn} + + {setup.host === 'settings' ? ( + + ) : null} +
+ ) : ( + + )} +
+ ) : null} + {!urlOnly && setup.authResolved && credentials.authMode === 'token' ? ( + + setup.setToken(event.target.value)} + placeholder={ + credentials.tokenSet ? g.existingToken(credentials.tokenPreview ?? g.savedToken) : copy.pasteSessionToken + } + type="password" + value={credentials.token} + /> + + ) : null} + {setup.error ?
{setup.error}
: null} + {setup.success ?
{setup.success}
: null} +
+ ) +} diff --git a/apps/desktop/src/components/remote-setup/first-run.tsx b/apps/desktop/src/components/remote-setup/first-run.tsx new file mode 100644 index 0000000000..5c7fe3a541 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/first-run.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import type { ReactElement } from 'react' + +import { BrandMark } from '@/components/brand-mark' +import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' +import { Loader2 } from '@/lib/icons' + +import { RemoteSetupFields } from './fields' +import { useRemoteSetup } from './use-remote-setup' + +interface FirstRunRemoteSetupProps { + onBack: () => void +} + +export function FirstRunRemoteSetup({ onBack }: FirstRunRemoteSetupProps): ReactElement { + const { t } = useI18n() + const copy = t.install + const setup = useRemoteSetup({ host: 'first-run' }) + const [applying, setApplying] = useState(false) + const [error, setError] = useState(null) + + const apply = async (): Promise => { + if (!setup.canCommit || applying) { + return + } + + setApplying(true) + setError(null) + + try { + await window.hermesDesktop.applyConnectionConfig(setup.payload) + onBack() + } catch (err) { + setError(err instanceof Error ? err.message : String(err || t.settings.gateway.applyFailed)) + } finally { + setApplying(false) + } + } + + return ( +
+
+
+ +
+

{copy.remoteSetupTitle}

+

{copy.remoteSetupDesc}

+
+
+
+ + {error ?
{error}
: null} +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx new file mode 100644 index 0000000000..e15cf9443d --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx @@ -0,0 +1,100 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopConnectionProbeResult } from '@/global' + +import { useRemoteSetup } from './use-remote-setup' +import type { RemoteSetupHost } from './use-remote-setup' + +function probeResult(authMode: 'oauth' | 'token', label: string): DesktopConnectionProbeResult { + return { + authMode, + baseUrl: `https://${label}.example`, + reachable: true, + error: null, + version: null, + providers: [{ name: label, displayName: label }] + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + Reflect.deleteProperty(window, 'hermesDesktop') +}) + +describe('remote setup owner', () => { + it.each(['first-run', 'settings', 'registry'])( + 'rejects stale probe results in %s despite fresh host callbacks', + async host => { + const replies: ((value: DesktopConnectionProbeResult) => void)[] = [] + + const probeConnectionConfig = vi.fn( + () => + new Promise(resolve => { + replies.push(resolve) + }) + ) + + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { probeConnectionConfig } }) + const { result, rerender } = renderHook(() => useRemoteSetup({ host, onNotice: () => {} })) + act(() => { + result.current.setAuthMode('oauth') + result.current.setUrl('https://a.example') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + act(() => result.current.setUrl('https://b.example')) + rerender() + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + expect(probeConnectionConfig.mock.calls).toEqual([['https://a.example'], ['https://b.example']]) + await act(async () => { + replies[1](probeResult('oauth', 'new')) + replies[0](probeResult('token', 'old')) + }) + expect(result.current.payload).toEqual({ + mode: 'remote', + remoteUrl: 'https://b.example', + remoteAuthMode: 'oauth', + remoteToken: undefined + }) + expect(result.current.providerLabel).toBe('new') + } + ) + + it('does not publish login completion after the editor unmounts without a probe bridge', async () => { + const onNotice = vi.fn() + let finish!: (value: { connected: boolean }) => void + + const oauthLoginConnectionConfig = vi.fn( + () => + new Promise<{ connected: boolean }>(resolve => { + finish = resolve + }) + ) + + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { oauthLoginConnectionConfig } }) + const { result, unmount } = renderHook(() => useRemoteSetup({ host: 'registry', onNotice })) + act(() => { + result.current.setAuthMode('oauth') + result.current.setUrl('https://a.example') + }) + let login!: Promise + await act(async () => { + login = result.current.signIn() + }) + expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://a.example') + unmount() + await act(async () => { + finish({ connected: true }) + await login + }) + expect(onNotice).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.ts b/apps/desktop/src/components/remote-setup/use-remote-setup.ts new file mode 100644 index 0000000000..f7c2bc6fe9 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.ts @@ -0,0 +1,371 @@ +import { useEffect, useRef, useState } from 'react' + +import type { DesktopConnectionConfigInput, DesktopConnectionProbeResult } from '@/global' +import { useI18n } from '@/i18n' +import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' +import { coerceRemoteUrlScheme } from '@/lib/remote-url' +import type { NotificationInput } from '@/store/notifications' + +export type RemoteSetupHost = 'first-run' | 'settings' | 'registry' +type AuthMode = 'oauth' | 'token' +type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' + +export interface RemoteCredentials { + url: string + authMode: AuthMode + token: string + tokenSet: boolean + tokenPreview: string | null + oauthConnected: boolean +} + +export interface RemoteSetupOptions { + host: RemoteSetupHost + enabled?: boolean + beforeOAuthLogin?: (payload: DesktopConnectionConfigInput) => Promise + onNotice?: (notice: NotificationInput) => void +} + +export interface RemoteSetup { + host: RemoteSetupHost + credentials: RemoteCredentials + payload: DesktopConnectionConfigInput + probeStatus: ProbeStatus + authResolved: boolean + providerLabel: string + isPassword: boolean + signingIn: boolean + testing: boolean + error: string | null + success: string | null + canTest: boolean + canCommit: boolean + setUrl: (url: string) => void + setToken: (token: string) => void + setAuthMode: (mode: AuthMode) => void + reset: (saved?: Partial) => void + signIn: () => Promise + signOut: () => Promise + test: () => Promise +} + +function credentialsFrom(saved: Partial = {}): RemoteCredentials { + return { url: '', authMode: 'token', token: '', tokenSet: false, tokenPreview: null, oauthConnected: false, ...saved } +} + +/** + * Host contracts: + * first-run: no pre-save; Apply requires a test of this exact payload. + * settings: pre-save through beforeOAuthLogin; credentials permit Save/Apply. + * registry: explicit auth selection; storage-only Save may precede credentials. + * Persistence and live source changes belong to the host, never this editor. + */ +export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { + const { t } = useI18n() + const g = t.settings.gateway + const { host, enabled = true } = options + const callbacks = useRef(options) + callbacks.current = options + const [credentials, setCredentials] = useState(credentialsFrom) + const [revision, setRevision] = useState(0) + const [probe, setProbe] = useState(null) + const [probeStatus, setProbeStatus] = useState('idle') + const [signingIn, setSigningIn] = useState(false) + const [testing, setTesting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [testedKey, setTestedKey] = useState(null) + const targetSeq = useRef(0) + const testSeq = useRef(0) + const loginSeq = useRef(0) + const url = coerceRemoteUrlScheme(credentials.url) + const manualAuth = host === 'registry' + const probeEnabled = enabled && (!manualAuth || credentials.authMode === 'oauth') + + const payload: DesktopConnectionConfigInput = { + mode: 'remote', + remoteAuthMode: credentials.authMode, + remoteToken: credentials.authMode === 'token' ? credentials.token.trim() || undefined : undefined, + remoteUrl: url + } + + const payloadKey = JSON.stringify(payload) + const currentKey = useRef(payloadKey) + currentKey.current = payloadKey + const { isPassword, providerLabel } = deriveRemoteAuthProviderShape(probe?.providers, t.boot.failure.identityProvider) + + const authResolved = + manualAuth || + (probeStatus === 'done' && probe?.authMode !== 'unknown') || + (host === 'settings' && probeStatus === 'idle' && (credentials.tokenSet || credentials.oauthConnected)) + + const credentialReady = Boolean( + url && + (credentials.authMode === 'oauth' ? credentials.oauthConnected : credentials.token.trim() || credentials.tokenSet) + ) + + const canTest = enabled && Boolean(url) && ((authResolved && credentialReady) || probeStatus === 'error') + + const invalidateTest = (): void => { + testSeq.current += 1 + setTesting(false) + setTestedKey(null) + setError(null) + setSuccess(null) + } + + const invalidateTarget = (): void => { + targetSeq.current += 1 + loginSeq.current += 1 + setSigningIn(false) + setProbe(null) + setProbeStatus('idle') + invalidateTest() + } + + const reset = (saved?: Partial): void => { + invalidateTarget() + setCredentials(credentialsFrom(saved)) + setRevision(value => value + 1) + } + + const setUrl = (value: string): void => { + invalidateTarget() + setCredentials(current => ({ ...current, url: value, oauthConnected: false, tokenSet: false, tokenPreview: null })) + setRevision(value => value + 1) + } + + const setToken = (token: string): void => { + invalidateTest() + setCredentials(current => ({ ...current, token })) + } + + const setAuthMode = (authMode: AuthMode): void => { + invalidateTarget() + setCredentials(current => ({ ...current, authMode, oauthConnected: false })) + setRevision(value => value + 1) + } + + const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => { + const message = err instanceof Error ? err.message : String(err || g.testFailed) + setError(message) + callbacks.current.onNotice?.({ kind, title, message }) + } + + const reportSuccess = (message: string): void => { + setSuccess(message) + callbacks.current.onNotice?.({ kind: 'success', title: g.reachableTitle, message }) + } + + const acceptProbe = (result: DesktopConnectionProbeResult): void => { + invalidateTest() + setProbe(result) + setProbeStatus(result.reachable ? 'done' : 'error') + + if (!manualAuth && result.reachable && result.authMode !== 'unknown') { + const authMode = result.authMode + setCredentials(current => ({ + ...current, + authMode, + oauthConnected: authMode === 'oauth' && current.oauthConnected + })) + } + } + + // The effect reads current callbacks without restarting its debounce on each host render. + const acceptProbeRef = useRef<(result: DesktopConnectionProbeResult) => void>(acceptProbe) + acceptProbeRef.current = acceptProbe + // eslint-disable-next-line no-restricted-syntax -- request generations, not a reactive value mirror + useEffect(() => { + const seq = ++targetSeq.current + let timer: number | undefined + + const cancel = (): void => { + targetSeq.current += 1 + window.clearTimeout(timer) + } + + setProbe(null) + setProbeStatus('idle') + setSigningIn(false) + setTesting(false) + setTestedKey(null) + setSuccess(null) + + if (!probeEnabled || !/^https?:\/\//i.test(url) || !window.hermesDesktop?.probeConnectionConfig) { + return cancel + } + + setProbeStatus('probing') + timer = window.setTimeout(() => { + void window.hermesDesktop + .probeConnectionConfig(url) + .then(result => { + if (seq === targetSeq.current) { + acceptProbeRef.current(result) + } + }) + .catch(() => { + if (seq === targetSeq.current) { + setProbeStatus('error') + } + }) + }, 500) + + return cancel + }, [probeEnabled, revision, url]) + + const signIn = async (): Promise => { + if (!url || signingIn) { + return + } + + const target = targetSeq.current + const seq = ++loginSeq.current + const current = (): boolean => target === targetSeq.current && seq === loginSeq.current + invalidateTest() + setSigningIn(true) + + try { + await callbacks.current.beforeOAuthLogin?.({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url }) + + if (!current()) { + return + } + + const result = await window.hermesDesktop.oauthLoginConnectionConfig(url) + + if (!current()) { + return + } + + setCredentials(value => ({ ...value, oauthConnected: Boolean(result.connected) })) + + if (result.connected) { + callbacks.current.onNotice?.({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) }) + } else { + const message = host === 'first-run' ? t.install.signInIncomplete : t.boot.failure.signInIncompleteMessage + reportError( + result.error ? `${message}: ${result.error}` : message, + t.boot.failure.signInIncompleteTitle, + 'warning' + ) + } + } catch (err) { + if (current()) { + reportError(err, g.signInFailed) + } + } finally { + if (current()) { + setSigningIn(false) + } + } + } + + const signOut = async (): Promise => { + const target = targetSeq.current + const seq = ++loginSeq.current + const current = (): boolean => target === targetSeq.current && seq === loginSeq.current + invalidateTest() + setSigningIn(true) + + try { + await window.hermesDesktop.oauthLogoutConnectionConfig(url) + + if (current()) { + setCredentials(value => ({ ...value, oauthConnected: false })) + callbacks.current.onNotice?.({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) + } + } catch (err) { + if (current()) { + reportError(err, g.signOutFailed) + } + } finally { + if (current()) { + setSigningIn(false) + } + } + } + + const test = async (): Promise => { + if (!canTest) { + return + } + + const target = targetSeq.current + const seq = ++testSeq.current + + const current = (): boolean => + target === targetSeq.current && seq === testSeq.current && payloadKey === currentKey.current + + setTesting(true) + setError(null) + setSuccess(null) + setTestedKey(null) + + try { + if (!authResolved) { + const result = await window.hermesDesktop.probeConnectionConfig(url) + + if (current()) { + acceptProbeRef.current(result) + + if (!result.reachable || result.authMode === 'unknown') { + reportError(result.error || g.probeError) + } + } + + return + } + + const result = await window.hermesDesktop.testConnectionConfig(payload) + + if (!current()) { + return + } + + if (result.ok === false || result.reachable === false) { + throw new Error(result.error || g.testFailed) + } + + reportSuccess( + (host === 'first-run' ? t.install.testSucceeded : g.connectedTo)( + result.baseUrl || url, + result.version ?? undefined + ) + ) + setTestedKey(payloadKey) + } catch (err) { + if (current()) { + reportError(err) + } + } finally { + if (current()) { + setTesting(false) + } + } + } + + return { + host, + credentials, + payload, + probeStatus, + authResolved, + providerLabel, + isPassword, + signingIn, + testing, + error, + success, + canTest, + canCommit: enabled && (host === 'first-run' ? testedKey === payloadKey : host === 'registry' || credentialReady), + setUrl, + setToken, + setAuthMode, + reset, + signIn, + signOut, + test + } +} From 9e8557b78ca6f1ac8a1eee3db5f5c37f3084aa03 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:07:50 -0400 Subject: [PATCH 19/33] Refuse missing snapshots and retain slots through late claims --- .../electron/backend-stop-overlap.test.ts | 96 ++++++++++++++++++- apps/desktop/electron/main.ts | 42 +++----- .../electron/pool-spawn-coordinator.ts | 44 ++++++++- .../updater/state-db-preflight.test.ts | 20 ++++ .../electron/updater/state-db-preflight.ts | 17 ++-- 5 files changed, 179 insertions(+), 40 deletions(-) diff --git a/apps/desktop/electron/backend-stop-overlap.test.ts b/apps/desktop/electron/backend-stop-overlap.test.ts index 09eaca82ad..2cd57774bc 100644 --- a/apps/desktop/electron/backend-stop-overlap.test.ts +++ b/apps/desktop/electron/backend-stop-overlap.test.ts @@ -6,7 +6,13 @@ import { test } from 'vitest' import { stopBackendChild, waitForBackendExit } from './backend-child' import { createLocalBackendLifecycle } from './local-backend-lifecycle' -import { releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator' +import { + assertPoolEntryStillOwned, + type LocalBackendSlotEntry, + LocalBackendSpawnCoordinator, + releaseLocalBackendSlot, + releaseLocalBackendSlotAfterExit +} from './pool-spawn-coordinator' import { createPoolStopper } from './pool-stop' test.skipIf(process.platform === 'win32')( @@ -87,3 +93,91 @@ test.skipIf(process.platform === 'win32')( } } ) + +test.skipIf(process.platform === 'win32')( + 'a claim completed after eviction retains its live child capacity', + async (): Promise => { + const slots = new LocalBackendSpawnCoordinator(1) + const signal = new AbortController().signal + const unspawned: LocalBackendSlotEntry = { releaseLocalBackendSlot: await slots.acquire('not-spawned') } + assert.throws((): void => assertPoolEntryStillOwned('not-spawned', unspawned, new Map(), signal)) + assert.equal(slots.activeCount, 0, 'pre-spawn cancellation releases its reservation') + + const physical = { + forceKillProcessTree: (): never => { + throw new Error('POSIX test') + } + } + + const lifecycle = createLocalBackendLifecycle({ + cancelSetup: (): void => {}, + stopChild: (child: ChildProcess): void => stopBackendChild(child, physical), + waitForExit: (child: ChildProcess): Promise => waitForBackendExit(child, physical) + }) + + const release = await slots.acquire('claiming') + + const child = lifecycle.spawn((): ChildProcess => + spawn( + process.execPath, + [ + '-e', + ` + process.on('SIGTERM', () => process.send('stopping')); + process.on('message', () => process.exit(0)); + setInterval(() => {}, 1000); + process.send('ready'); + ` + ], + { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] } + ) + ) + + child.once('exit', (): boolean => lifecycle.release(child)) + const entry = { process: child, releaseLocalBackendSlot: release } + const entries = new Map([['claiming', entry]]) + const pool = createPoolStopper({ pool: entries, stopChild: lifecycle.stop }) + let finishClaim!: () => void + + const claim = new Promise((resolve: () => void): void => { + finishClaim = resolve + }) + + const starting = claim.then((): void => assertPoolEntryStillOwned('claiming', entry, entries, signal)) + const rejected = assert.rejects(starting, /cancelled/) + + try { + await once(child, 'message') + const signalled = once(child, 'message') + + const eviction = releaseLocalBackendSlotAfterExit( + (): void => releaseLocalBackendSlot(entry), + (): Promise => pool.stop('claiming') + ) + + await signalled + finishClaim() + await rejected + assert.equal(child.exitCode, null) + assert.equal(child.signalCode, null) + assert.ok(pool.inFlight('claiming')) + assert.equal(slots.activeCount, 1, 'the post-claim guard must not release a live child slot') + const replacement = slots.request('different-profile') + assert.equal(replacement.queued, true) + child.send('exit') + await eviction + const releaseReplacement = await replacement.acquired + assert.equal(child.exitCode, 0) + releaseReplacement() + assert.equal(slots.activeCount, 0) + } finally { + finishClaim() + await rejected + + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + } + } +) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a735fc591a..1d37332161 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -310,10 +310,11 @@ import { import { selectPoolEvictions } from './pool-eviction' import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits' import { + assertPoolEntryStillOwned, isBackgroundSlotWaitTimeout, LocalBackendSpawnCoordinator, type LocalBackendSpawnPriority, - type LocalBackendSpawnRequest, + releaseLocalBackendSlot, releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator' import { createPoolStopper } from './pool-stop' @@ -11742,31 +11743,6 @@ function startPoolIdleReaper() { } } -function releaseLocalBackendSlot(entry: any) { - if (!entry) { - return - } - - const release = entry.releaseLocalBackendSlot - const request = entry.localBackendSpawnRequest as LocalBackendSpawnRequest | null - entry.releaseLocalBackendSlot = null - entry.localBackendSlotKey = null - entry.localBackendSpawnRequest = null - - if (release) { - release() - } else { - request?.cancel() - } -} - -function assertPoolEntryStillOwned(poolKey: string, entry: any): void { - if (localBackendLifecycle.signal.aborted || backendPool.get(poolKey) !== entry) { - releaseLocalBackendSlot(entry) - throw new Error(`Profile backend start for "${poolKey}" was cancelled before spawn.`) - } -} - const failedLocalBackendTeardowns = new WeakMap>() function teardownFailedLocalBackend(poolKey: string, entry: any): Promise { @@ -11805,7 +11781,11 @@ function teardownFailedLocalBackend(poolKey: string, entry: any): Promise // entry means THIS machine regardless of the v1 routing table); `opts.poolKey` // is the backendPool key when it differs from the profile name (composite // registry scopes) so the exit/error cleanup evicts the right entry. -async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; poolKey?: string } = {}) { +async function spawnPoolBackend( + profile: string, + entry: any, + opts: { forceLocal?: boolean; poolKey?: string } = {} +): Promise>> { const poolKey = opts.poolKey || profile await reapOrphanedBackendsOnce() @@ -11848,7 +11828,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po const spawnPriority: LocalBackendSpawnPriority = spawnPriorityFrom(entry.spawnPriority) - assertPoolEntryStillOwned(poolKey, entry) + assertPoolEntryStillOwned(poolKey, entry, backendPool, localBackendLifecycle.signal) const spawnRequest = localBackendSpawnCoordinator.request(poolKey, { timeoutMs: POOL_SLOT_WAIT_MS, @@ -11877,7 +11857,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po entry.localBackendSpawnRequest = null } - assertPoolEntryStillOwned(poolKey, entry) + assertPoolEntryStillOwned(poolKey, entry, backendPool, localBackendLifecycle.signal) const token = crypto.randomBytes(32).toString('base64url') @@ -11929,7 +11909,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po const parentStartMarker = await desktopParentStartMarker() const backendNonce = crypto.randomBytes(16).toString('hex') const parentIdentityEnv = parentWatchdogEnv(process.pid, parentStartMarker, backendNonce) - assertPoolEntryStillOwned(poolKey, entry) + assertPoolEntryStillOwned(poolKey, entry, backendPool, localBackendLifecycle.signal) const child = spawnOwnedBackend( backend.command, @@ -11984,7 +11964,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po // surface as an unhandled rejection before the Promise.race below attaches. portAnnouncement.catch(() => {}) await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce, outputTail) - assertPoolEntryStillOwned(poolKey, entry) + assertPoolEntryStillOwned(poolKey, entry, backendPool, localBackendLifecycle.signal) child.stdout.on('data', rememberLog) child.stderr.on('data', rememberLog) diff --git a/apps/desktop/electron/pool-spawn-coordinator.ts b/apps/desktop/electron/pool-spawn-coordinator.ts index 5f80af332d..11c9c8a69f 100644 --- a/apps/desktop/electron/pool-spawn-coordinator.ts +++ b/apps/desktop/electron/pool-spawn-coordinator.ts @@ -42,6 +42,47 @@ export function isBackgroundSlotWaitTimeout(error: unknown): boolean { return error instanceof LocalBackendSlotWaitTimeoutError && error.silent } +export interface LocalBackendSlotEntry { + process?: unknown + releaseLocalBackendSlot?: ReleaseLocalBackendSlot | null + localBackendSlotKey?: string | null + localBackendSpawnRequest?: LocalBackendSpawnRequest | null +} + +export function releaseLocalBackendSlot(entry: LocalBackendSlotEntry | undefined): void { + if (!entry) { + return + } + + const release = entry.releaseLocalBackendSlot + const request = entry.localBackendSpawnRequest + entry.releaseLocalBackendSlot = null + entry.localBackendSlotKey = null + entry.localBackendSpawnRequest = null + + if (release) { + release() + } else { + request?.cancel() + } +} + +export function assertPoolEntryStillOwned( + poolKey: string, + entry: LocalBackendSlotEntry, + pool: ReadonlyMap, + signal: AbortSignal +): void { + if (signal.aborted || pool.get(poolKey) !== entry) { + // A post-claim cancellation still owns a child. Its exit releases capacity. + if (!entry.process) { + releaseLocalBackendSlot(entry) + } + + throw new Error(`Profile backend start for "${poolKey}" was cancelled during start.`) + } +} + export async function releaseLocalBackendSlotAfterExit( release: ReleaseLocalBackendSlot, waitForExit: () => Promise @@ -54,7 +95,8 @@ export async function releaseLocalBackendSlotAfterExit( * Bounds the number of local profile backends that are starting or running. * * A lease is acquired immediately before local start work and is held until - * the child exits or the start fails. Remote descriptors never call request(). + * the child exits, or a start is cancelled before spawn. Remote descriptors + * never call request(). * * When the cap is at least 2, one slot is reserved for foreground (user-open) * requests so background roster hydration cannot occupy the whole pool. diff --git a/apps/desktop/electron/updater/state-db-preflight.test.ts b/apps/desktop/electron/updater/state-db-preflight.test.ts index 8f6a42fcc2..ab5267225d 100644 --- a/apps/desktop/electron/updater/state-db-preflight.test.ts +++ b/apps/desktop/electron/updater/state-db-preflight.test.ts @@ -86,3 +86,23 @@ with sqlite3.connect(sys.argv[1]) as c: fs.rmSync(home, { recursive: true, force: true }) } }) + +test('an older selected checkout without the snapshot helper refuses before backend stop', (): void => { + const oldRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), 'old-preflight-')) + let stopped = false + + try { + assert.throws((): void => { + preflightStateDb({ + python: process.env.HERMES_PYTHON || 'python3', + script: path.join(oldRoot, 'hermes_cli', 'backup_sqlite.py'), + home: oldRoot, + log: (): void => {} + }) + stopped = true + }, /snapshot|pre-flight/) + assert.equal(stopped, false) + } finally { + fs.rmSync(oldRoot, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/updater/state-db-preflight.ts b/apps/desktop/electron/updater/state-db-preflight.ts index d7c0b5ca62..bbc8ffdf61 100644 --- a/apps/desktop/electron/updater/state-db-preflight.ts +++ b/apps/desktop/electron/updater/state-db-preflight.ts @@ -11,13 +11,11 @@ interface StateDbPreflight { // Synchronous by design: the caller must not stop the backend before the snapshot. export function preflightStateDb({ python, script, home, log }: StateDbPreflight): void { - if (!python) { - log('[updates] state.db pre-flight unavailable: Python not found') - - return - } - try { + if (!python) { + throw new Error('Python not found') + } + const result: string = execFileSync( python, ['-I', '-S', script, home], @@ -26,6 +24,11 @@ export function preflightStateDb({ python, script, home, log }: StateDbPreflight log(`[updates] state.db pre-flight: ${result.trim()}`) } catch (error: unknown) { - log(`[updates] state.db pre-flight failed: ${error instanceof Error ? error.message : String(error)}`) + const message = + `state.db pre-flight failed: ${error instanceof Error ? error.message : String(error)}. ` + + 'Update cancelled before backend shutdown. Update the selected installation with its hermes update command, then retry.' + + log(`[updates] ${message}`) + throw new Error(message, { cause: error }) } } From f5c84ee90a0625e8ed22488c2657aaf44ee80ab2 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:09:23 -0400 Subject: [PATCH 20/33] Exercise update-check pruning against a real shallow fixture --- tests/hermes_cli/test_shallow_graft_prune.py | 45 +++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/tests/hermes_cli/test_shallow_graft_prune.py b/tests/hermes_cli/test_shallow_graft_prune.py index df96e312c8..c444711b71 100644 --- a/tests/hermes_cli/test_shallow_graft_prune.py +++ b/tests/hermes_cli/test_shallow_graft_prune.py @@ -14,14 +14,9 @@ from __future__ import annotations import subprocess from pathlib import Path -from types import SimpleNamespace -from unittest.mock import MagicMock from hermes_cli.gitlock import prune_stale_shallow_grafts -SHA_A = "a" * 40 -SHA_B = "b" * 40 - def _git(repo: Path, *args: str) -> str: result = subprocess.run( @@ -94,34 +89,32 @@ def test_update_check_prunes_and_reports_count(tmp_path, monkeypatch, capsys): """`hermes update --check` prunes grafts after its depth-1 fetch and reports the prune.""" import hermes_cli.update_cmd as update_cmd - fake_root = SimpleNamespace(PROJECT_ROOT=tmp_path) - monkeypatch.setattr(update_cmd, "_m", lambda: fake_root) - (tmp_path / ".git").mkdir() + clone = _mk_shallow_scenario(tmp_path) + assert len(_shallow_lines(clone)) == 3 + head_sha = _git(clone, "rev-parse", "HEAD") + previous_tip = _git(clone, "rev-parse", "origin/main") + origin = tmp_path / "origin" + _git(origin, "commit", "--allow-empty", "-q", "-m", "c5") + tip_sha = _git(origin, "rev-parse", "HEAD") + + # The check runs git directly, reading main.PROJECT_ROOT through _m(). + monkeypatch.setattr(update_cmd._m(), "PROJECT_ROOT", clone) monkeypatch.setattr( "hermes_cli.update_contract.evaluate_update_admission", lambda root: None ) - monkeypatch.setattr(update_cmd, "_is_shallow_checkout", lambda git_cmd: True) - monkeypatch.setattr(update_cmd, "_tip_shas", lambda git_cmd, branch: (SHA_A, SHA_B)) - - def fake_git_run(git_cmd, args, **kwargs): - joined = " ".join(args) - if "get-url" in joined and "upstream" in joined: - return MagicMock(returncode=1, stdout="", stderr="") # no upstream remote - if "fetch" in joined: - return MagicMock(returncode=0, stdout="", stderr="") # depth-1 fetch lands - return MagicMock(returncode=0, stdout="", stderr="") - - monkeypatch.setattr(update_cmd, "_git_run", fake_git_run) - monkeypatch.setattr(update_cmd, "_base_git_cmd", lambda: ["git"]) - monkeypatch.setattr("hermes_cli.source_check._github_compare_behind", lambda *a, **k: 0) - prune_calls = [] + # Local fixture commits have no GitHub compare result; keep the check offline. monkeypatch.setattr( - "hermes_cli.gitlock.prune_stale_shallow_grafts", - lambda repo: prune_calls.append(repo) or 2, + "hermes_cli.source_check._github_compare_behind", lambda *a, **k: None ) update_cmd._cmd_update_check("main") out = capsys.readouterr().out - assert prune_calls == [tmp_path] + assert _git(clone, "rev-parse", "HEAD") == head_sha + assert _git(clone, "rev-parse", "origin/main") == tip_sha != previous_tip + assert _git(clone, "rev-parse", "FETCH_HEAD") == tip_sha + assert set(_shallow_lines(clone)) == {head_sha, tip_sha} + assert _git(clone, "rev-list", "--count", "HEAD") == "1" + assert _git(clone, "rev-list", "--count", "origin/main") == "1" assert "pruned 2 stale shallow graft(s)" in out + assert "Update available (behind origin/main)." in out From 93cffdbd3a9c45fbcf99bcdbcdd89cf3e8a31e9e Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:09:29 -0400 Subject: [PATCH 21/33] refactor(update): finish source updates in fresh selected Python --- docs/source-update-completion.md | 80 ++++ hermes_cli/AGENTS.md | 10 +- hermes_cli/_old_updater.py | 7 +- hermes_cli/update_cmd.py | 166 ++++---- hermes_cli/update_cmd_config.py | 53 +-- hermes_cli/update_cmd_fleet.py | 125 +----- hermes_cli/update_cmd_maint.py | 149 +------ hermes_cli/update_cmd_zip.py | 48 +-- hermes_cli/update_completion.py | 265 ++++++++++++ tests/compat/old_updater_surface.json | 308 +++++++++++++- tests/hermes_cli/conftest.py | 18 +- tests/hermes_cli/test_cmd_update.py | 62 +-- .../test_pending_supervisor_recovery.py | 45 -- .../test_source_release_channels.py | 12 +- .../test_update_completion_process.py | 391 ++++++++++++++++++ .../test_update_completion_routing.py | 50 +++ ...test_update_config_migration_on_current.py | 13 +- ...te_config_migration_on_current_checkout.py | 71 +--- .../test_update_desktop_stale_warning.py | 4 +- .../test_update_fleet_restart_pending.py | 64 +-- .../test_update_handoff_desktop_rebuild.py | 53 --- tests/hermes_cli/test_update_products.py | 52 --- .../test_update_sqlite_remediation.py | 30 +- .../hermes_cli/test_update_stale_dashboard.py | 72 ---- .../test_update_stale_module_purge.py | 173 -------- .../test_update_state_autorestore.py | 6 + .../test_update_zip_sync_failure.py | 114 +---- website/docs/reference/package-management.md | 17 +- 28 files changed, 1333 insertions(+), 1125 deletions(-) create mode 100644 docs/source-update-completion.md create mode 100644 hermes_cli/update_completion.py create mode 100644 tests/hermes_cli/test_update_completion_process.py create mode 100644 tests/hermes_cli/test_update_completion_routing.py delete mode 100644 tests/hermes_cli/test_update_handoff_desktop_rebuild.py delete mode 100644 tests/hermes_cli/test_update_stale_module_purge.py diff --git a/docs/source-update-completion.md b/docs/source-update-completion.md new file mode 100644 index 0000000000..8aa34a437b --- /dev/null +++ b/docs/source-update-completion.md @@ -0,0 +1,80 @@ +# Source update completion ownership + +## Phase seam + +The command process owns admission, the update lock and output lifetime, pre-update +inventory, all-profile snapshots, gateway pause, Git selection/stash/restore and +syntax/HEAD guards, and the ZIP download/stage/dirty recheck/release graft/swap. +It imports the completion transport before swapping code. Once the final tree is +selected (including upstream merge), Git, already-current retry and ZIP all send +one versioned JSON request to `update_completion.py` **from that tree**. No cached +application module is evicted or reloaded in the command process. + +The request carries canonical source/home, desktop product selection, interactive +and gateway mode, pre-update version, active and sibling snapshot identifiers, +serialized runtime plan, open receipt identity/data and paused-Windows token. It +contains data, never callables or pickles. stdin stays inherited for interactive +configuration prompts; gateway mode retains its non-interactive behavior. Child +output stays visible and is mirrored by the parent's update output stream. + +## New-code owner + +A stdlib-only entrypoint starts using the available Python with `-I -S`, so no +old site-packages or executable `.pth` files initialize. A private bytecode-cache +prefix fences stale cache files before any new-checkout imports. Its explicit +import path points at the new checkout. It calls the new PM interface to prepare the +recorded dependency union, then starts the selected Python with the new activation +environment. That interpreter also starts with site initialization disabled, +then the runtime owner leases and activates its selected generation before any +application imports. Only that interpreter imports application completion code. The same +receipt/correlation identity crosses this preparation boundary (including PM +results). Selected-Python completion owns launcher publication, builders, cache +invalidation, all-profile configuration/state/skills maintenance, process scans, +fleet restart, Windows resume, dashboard deduplication and verification. + +The existing per-kind restart and abort-recovery algorithms remain; transient +supervisor/process failures are real even without mixed-generation imports. Only +the purge/reload workaround and independent retry/ZIP tail compositions disappear. +Gateway exit status is written before a restart can terminate the updater's cgroup, +and is demoted on later failure. Verification publishes the final receipt. + +## Parent lifecycle and failures + +The parent waits and propagates the child's exact nonzero result (a signal is +mapped to shell-style 128+signal). A child cannot succeed by merely exiting zero: +a terminal response with the matching receipt identity is required. The response +returns the mutated Windows token so the parent's registered emergency resume does +not repeat completed work. Normal parent completion performs no maintenance. + +The parent retains its original receipt until acknowledged child finalization; +missing/failed child output leaves it available to the existing command-boundary +failure finalizer. The stdlib bootstrap returns correlated PM failure data even +when application imports are unavailable, and normalizes negative signal exits +at each process boundary. POSIX completion owns a new session/process group; +cancellation kills that group before releasing the lock (Windows uses the retained +child's `taskkill /T` tree). The parent records the pending fleet obligation before +starting the completion process, including when preparation cannot begin. The parent's emergency Windows resume remains a last-resort +lifecycle obligation when the child cannot execute or is killed. A failed child +never clears the pending fleet obligation. No automatic code rollback after +maintenance has begun (SQLite snapshots remain file-loss recovery, not rollback). + +## Historical surface + +All names frozen from the complete reachable shipped updater history stay +resolvable. Retired preparation and module-reload hooks become narrow nonzero +relaunch stops, not alternate completion paths or false successful receipts. +Unfrozen branch-only retry compositions are deleted, not shimmed. ACP convenience +publication uses the launcher owner's `expose_cli`; the historical ACP entry is +only an adapter, never a second writer. The frozen set is never trimmed or replaced +with tag-only coverage. New current-path imports are unioned with that history. + +## Verification + +Use isolated homes, disposable Git repositories and fake dependency/build/service +adapters only. Exercise an old process with cached incompatible modules across a +real Git transition to new code, selected-Python execution, receipt identity and +snapshot transfer, nonzero/abrupt child exit, lock release and Windows-token +return. Focused existing tests cover dirty ZIP checks/grafts, snapshots, fleet +reconciliation, supervisor timing and historical imports. Native service restart +and Windows/macOS acceptance remain separate required lanes; no live user service +or user state is touched by this implementation's test runs. diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index abbaf5e942..9647120ffe 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -114,9 +114,13 @@ it guards. `plan → snapshot → apply → restart-per-kind → verify → repo (exit 1) — automation must never treat a mixed-version fleet as healthy. - **Report**: every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/` (`latest.json` pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot). - Finalization is owned by the `cmd_update` command boundary — early `sys.exit` paths (preflight - refusals, fetch failures) still persist a receipt with the real exit code. A begun-but-unwritten - receipt is a bug: refused/failed runs are the ones receipts exist for. + Before a source swap, the parent captures plan/snapshots/receipt and its Windows pause token. + `update_completion.py` runs new-code PM preparation with site initialization disabled, then + selected-Python builds, maintenance, scans/restarts and verification. Git/current/ZIP share + this owner; never reload or purge modules to continue in the old interpreter. The parent keeps + the lock, waits, and accepts only a correlated terminal result. `cmd_update` still finalizes + early failures and missing/killed-child outcomes; PM refusal data survives the handoff. + See `docs/source-update-completion.md`. A begun-but-unwritten receipt is a bug. Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read diff --git a/hermes_cli/_old_updater.py b/hermes_cli/_old_updater.py index 89a4bca1c8..d72c33965b 100644 --- a/hermes_cli/_old_updater.py +++ b/hermes_cli/_old_updater.py @@ -4,11 +4,12 @@ import sys from typing import NoReturn -def stop_for_relaunch() -> NoReturn: +def stop_for_relaunch(*, incomplete: bool = False) -> NoReturn: """Do not return: old callers would fall back to pip or claim completion.""" + command = "hermes update" if incomplete else "hermes" print( "You're updating from an older version of Hermes Agent. " - "To complete this update, run `hermes` again.", + f"To complete this update, run `{command}` again.", file=sys.stderr, ) - raise SystemExit(0) + raise SystemExit(1 if incomplete else 0) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 2513fe5951..b655d9ef63 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -20,6 +20,10 @@ from typing import NoReturn from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd) from hermes_cli.update_cmd_common import _best_effort +# Captured BEFORE a checkout swap: parent transport/lifecycle never imports new code. +from hermes_cli.update_completion import run_completion +from pm.receipt import accept_worker_receipt as _accept_completion_pm_receipt +from hermes_cli import update_receipt as _completion_receipt, update_cmd_config as _completion_config from hermes_cli._old_updater import stop_for_relaunch from hermes_constants import venv_python_path @@ -43,14 +47,14 @@ from hermes_cli.update_cmd_windows import ( # noqa: F401 _wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker) from hermes_cli.update_cmd_fleet import ( # noqa: F401 _FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome, - _apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker, + _clear_fleet_restart_pending_marker, _current_checkout_sha, _drain_or_signal_gateway_for_update, _fleet_probe_expected_runtimes, _fleet_restart_pending_marker_path, _for_each_systemd_gateway_unit, _gateway_recovery_partition, _gateway_service_matches_profile, _pending_fleet_restart_needed, _receipt_looks_unfinished, _receipt_reports_stale_runtime, _resolve_manage_cmd, _restart_gateway_fleet_after_update, _restart_launchd_gateway_after_update, _restart_macos_launchd_gateways, _restart_phase_failure_is_incomplete, - _restart_systemd_gateway_units, _restart_systemd_gateway_units_best_effort, + _restart_systemd_gateway_units, _run_pending_fleet_restart, _service_restart_sec, _service_unit_supports_graceful_sigusr1_restart, _surviving_gateway_pids_after_failed_restart, _systemctl, _systemctl_reset_and_restart, _verify_fleet_after_update, @@ -93,8 +97,7 @@ from hermes_cli.update_cmd_git import ( # noqa: F401 _prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream, _sync_with_upstream_if_needed) from hermes_cli.update_cmd_maint import ( # noqa: F401 - _PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES, - _STALE_PURGE_PROTECTED, _clear_stale_sqlite_sidecars, + _PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _clear_stale_sqlite_sidecars, _ensure_acp_launcher, _ensure_fhs_path_guard, _finish_dashboard_update_cleanup, _format_time_ago, _post_update_sqlite_runtime_status, _print_bundled_skills_sync_report, _print_curator_first_run_notice, _print_curator_recent_run_notice, @@ -788,18 +791,45 @@ def _print_update_check_result(behind: int | None, compare_branch: str) -> None: print(f" Run '{recommended_update_command()}' to install.") -def _repair_current_checkout( - *, assume_yes, gateway_mode, pre_update_snapshot_id, - had_desktop_app_before_update, upstream_checked) -> bool: - """A retry completes the same products as a newly pulled checkout.""" - _prepare_updated_checkout( - _m().PROJECT_ROOT, desktop=had_desktop_app_before_update) - _check_and_apply_config_migration( - assume_yes=assume_yes, gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id) - return _print_verified_update_completion( - "✓ Already up to date!" if upstream_checked - else "✓ Up to date with your fork (official repo not checked).") +def _source_completion_request(opts, plan, snapshot_id, windows_resume, desktop, gateway_mode) -> dict: + """Freeze data before mutation; no pre-swap module objects cross the seam.""" + from copy import deepcopy + current = _completion_receipt._current.get() + if current is None: + _completion_receipt.begin_update_receipt() + current = _completion_receipt._current.get() + return { + "schema": 1, "source": str(_m().PROJECT_ROOT.resolve()), + "home": str(get_hermes_home()), "branch": "main", "desktop": desktop, + "assume_yes": opts.assume_yes, "gateway_mode": gateway_mode, + "pre_update_version": opts.pre_update_version, "snapshot_id": snapshot_id, + "sibling_snapshots": deepcopy(_completion_config._LAST_SIBLING_SNAPSHOTS), + "plan": plan.to_dict() if plan is not None else None, + "receipt": deepcopy(current.data), "windows_resume": windows_resume, + } + + +def _complete_source_update(request: dict | None) -> None: + if request is None: + stop_for_relaunch(incomplete=True) + from copy import deepcopy + current = _completion_receipt._current.get() + if current is not None: + request["receipt"] = deepcopy(current.data) + _write_fleet_restart_pending_marker(expected_sha=request.get("expected_sha") or "") + result = run_completion(request) + _accept_completion_pm_receipt(result.get("pm_receipt"), request["receipt"]["update_id"]) + token = request["windows_resume"] + if token is not None and result.get("windows_resume") is not None: + resumed = dict(result["windows_resume"]) + token.clear() + token.update(resumed) + if result.get("receipt") is not None: + current = _completion_receipt._current.get() + if current is not None: + _completion_receipt._current.reset(current.current_token) + if result["exit_code"]: + raise SystemExit(result["exit_code"]) def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_ref=None) -> None: @@ -1230,7 +1260,7 @@ def _current_branch_name(git_cmd, *, check: bool = False) -> str: def _handle_update_called_process_error( e, args, gateway_mode: bool, had_desktop_app_before_update: bool, - *, target_sha: str | None = None, target_repository: str | None = None) -> None: + *, target_sha: str | None = None, target_repository: str | None = None, completion_request=None) -> None: """Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``.""" stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): @@ -1239,12 +1269,9 @@ def _handle_update_called_process_error( print() update_complete = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, - target_sha=target_sha, + target_sha=target_sha, completion_request=completion_request, **({"target_repository": target_repository} if target_repository else {})) - if gateway_mode: - _write_gateway_update_exit_code(update_complete) - if not update_complete: - sys.exit(1) + else: print(f"✗ {stage}: {e}") _print_called_process_error_tail(e) @@ -1269,12 +1296,9 @@ def _finalize_receipt(status: str, debug_message: str) -> None: def _finish_already_up_to_date( - git_cmd, branch: str, current_branch: str, _plan, *, assume_yes: bool, gateway_mode: bool, - gw_input_fn, pre_update_snapshot_id, had_desktop_app_before_update: bool, - _windows_gateway_resume) -> None: + git_cmd, branch: str, current_branch: str, _plan, *, gw_input_fn, completion_request: dict) -> None: """"Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet. ``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt).""" - # Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean + # fully merged stays on the target — re-parking on the stale branch recreates the incident. if _plan.auto_stash_ref is not None: @@ -1292,69 +1316,27 @@ def _finish_already_up_to_date( elif current_branch not in {branch, "HEAD"}: _git_run(git_cmd, ["checkout", current_branch]) - current_checkout_complete = _repair_current_checkout( - assume_yes=assume_yes, gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - had_desktop_app_before_update=had_desktop_app_before_update, - upstream_checked=_plan.upstream_checked) - _m()._resume_windows_gateways_after_update(_windows_gateway_resume) - # A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit - # gate so a partial outcome can't strand the fleet on stale code. - # Catch up even on the "Already up to date" path — that early return is what left the gateway on stale - # code for two days. Runs BEFORE the runtime-verification exit gate below: a vulnerable SQLite runtime - # demotes the outcome to partial, but must not strand the fleet on stale code (#91277 fleet contract — - # the pending-restart check always executes). - _apply_pending_fleet_restart_catchup() - if not current_checkout_complete: - if gateway_mode: - _write_gateway_update_exit_code(False) - _finalize_receipt("partial", 'Update receipt finalize (current checkout) failed: %s') - sys.exit(1) + if completion_request is not None: + completion_request["completion_message"] = ( + "✓ Already up to date!" if _plan.upstream_checked + else "✓ Up to date with your fork (official repo not checked).") + _complete_source_update(completion_request) def _apply_pulled_update( - git_cmd, branch, pre_pull_sha, _plan, opts, *, gateway_mode, is_fork, desktop_dir, - had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan, - _windows_gateway_resume) -> None: + git_cmd, branch, pre_pull_sha, _plan, opts, *, is_fork, + _windows_gateway_resume, completion_request: dict) -> None: """Post-pull phase: verify HEAD, sync Python/Node/web/Desktop, maintenance, fleet restart.""" post_pull_sha = _verify_head_after_pull( git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update, _windows_gateway_resume=_windows_gateway_resume) - # Gateways still serve pre-pull modules until the restart phase; an interrupt before a - # completed restart leaves this marker so the next update catches up even when git is - # current. Distinct from ``.update-incomplete`` (venv/install repair). - # See #95294. - _write_fleet_restart_pending_marker(expected_sha=post_pull_sha or "") - # Stale .pyc would ImportError on gateway restart when new source references new names. - _sweep_bytecode_after_update(branch) - if is_fork and branch == "main": _m()._sync_with_upstream_if_needed( git_cmd, _m().PROJECT_ROOT, assume_yes=opts.assume_yes, input_fn=opts.gw_input_fn) - - _prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update) - - print() - print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") - - update_complete = _run_post_update_maintenance( - assume_yes=opts.assume_yes, gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - had_desktop_app_before_update=had_desktop_app_before_update, - pre_update_version=opts.pre_update_version) - - # Exit code *before* the restart: under --gateway this process lives in the gateway's - # systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so - # the marker would never land and the new gateway's watcher would time out spuriously. - if gateway_mode: - _write_gateway_update_exit_code(update_complete) - - _restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode) - _resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode) - _verify_fleet_after_update( - _restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, - update_complete=update_complete) + if completion_request is not None: + completion_request["expected_sha"] = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) or post_pull_sha + _complete_source_update(completion_request) def _cmd_update_impl(args, gateway_mode: bool): @@ -1369,6 +1351,7 @@ def _cmd_update_impl(args, gateway_mode: bool): # Backup before any git/file mutation; the snapshot id (None if disabled/failed) feeds # the post-update cron-jobs safety net. + _completion_config._LAST_SIBLING_SNAPSHOTS = {} pre_update_snapshot_id = _m()._run_pre_update_backup(args) _record_update_step( "pre_update_backup", pre_update_snapshot_id is not None, @@ -1387,7 +1370,11 @@ def _cmd_update_impl(args, gateway_mode: bool): use_zip_update, git_cmd, is_fork = _prepare_git_command() + completion_request = _source_completion_request( + opts, _pre_update_plan, pre_update_snapshot_id, _windows_gateway_resume, + had_desktop_app_before_update, gateway_mode) branch = _m()._resolve_update_branch(args) + completion_request["branch"] = branch target_ref = f"origin/{branch}" release_tag, release_sha = None, None target_repository = None @@ -1419,14 +1406,11 @@ def _cmd_update_impl(args, gateway_mode: bool): try: update_complete = _update_via_zip( args, had_desktop_app_before_update=had_desktop_app_before_update, - target_sha=release_sha, + target_sha=release_sha, completion_request=completion_request, **({"target_repository": target_repository} if target_repository else {})) finally: _m()._resume_windows_gateways_after_update(_windows_gateway_resume) - if gateway_mode: - _write_gateway_update_exit_code(update_complete) - if not update_complete: - sys.exit(1) + return try: @@ -1482,11 +1466,8 @@ def _cmd_update_impl(args, gateway_mode: bool): if commit_count == 0: _finish_already_up_to_date( - git_cmd, branch, current_branch, _plan, assume_yes=assume_yes, - gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, - pre_update_snapshot_id=pre_update_snapshot_id, - had_desktop_app_before_update=had_desktop_app_before_update, - _windows_gateway_resume=_windows_gateway_resume) + git_cmd, branch, current_branch, _plan, gw_input_fn=gw_input_fn, + completion_request=completion_request) return if release_tag: @@ -1503,16 +1484,13 @@ def _cmd_update_impl(args, gateway_mode: bool): gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes, keep_stash=opts.keep_stash, target_ref=target_ref) _apply_pulled_update( - git_cmd, branch, pre_pull_sha, _plan, opts, gateway_mode=gateway_mode, - is_fork=is_fork and not release_tag, desktop_dir=desktop_dir, - had_desktop_app_before_update=had_desktop_app_before_update, - pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan, - _windows_gateway_resume=_windows_gateway_resume) + git_cmd, branch, pre_pull_sha, _plan, opts, is_fork=is_fork and not release_tag, + _windows_gateway_resume=_windows_gateway_resume, completion_request=completion_request) except subprocess.CalledProcessError as e: try: _handle_update_called_process_error( e, args, gateway_mode, had_desktop_app_before_update, target_sha=release_sha, - target_repository=target_repository) + target_repository=target_repository, completion_request=completion_request) finally: _m()._resume_windows_gateways_after_update(_windows_gateway_resume) diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index 53cc4b73d2..bebe000da7 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -13,37 +13,19 @@ logger = logging.getLogger("hermes_cli.update_cmd") def _reload_config_modules() -> None: - """Force-reload config modules after git pull: the updater is the PRE-pull process, so the - cached modules hold OLD code and ``check_config_version()`` would report "up to date" despite a - pulled migration. ``_subprocess_compat`` / ``dashboard_procs`` reload too so the later dashboard - cleanup sees symbols the update added.""" - import importlib - importlib.invalidate_caches() - for mod_name in ( - "hermes_cli.config_defaults", "hermes_cli.config", "hermes_cli.config_migrations", - "hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs"): - mod = sys.modules.get(mod_name) - if mod is not None: - try: - importlib.reload(mod) - except Exception as exc: - logger.debug("Could not reload %s for fresh post-update code: %s", mod_name, exc) + """Historical updater hook; migration now belongs to fresh completion Python.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _run_config_check_fresh() -> tuple: - """``(current_ver, latest_ver)`` from freshly-reloaded modules (see ``_reload_config_modules``).""" - from hermes_cli.update_cmd import _reload_config_modules - _reload_config_modules() - from hermes_cli.config import check_config_version - return check_config_version(raise_on_parse_error=True) + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict: - """Run config migration with freshly-reloaded modules; returns the results dict.""" - from hermes_cli.update_cmd import _reload_config_modules - _reload_config_modules() - from hermes_cli.config import migrate_config - return migrate_config(interactive=interactive, quiet=quiet) + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: @@ -56,7 +38,7 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: profile, but ``hermes update`` historically migrated only the active profile's config — siblings drifted versions until their gateway hit a config the new code couldn't read. """ - from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh + from hermes_cli.config import check_config_version, migrate_config migrated: list[tuple[str, int, int]] = [] with _best_effort('Sibling profile enumeration failed: %s'): from hermes_constants import ( @@ -78,11 +60,11 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: continue # profile never configured — nothing to migrate token = set_hermes_home_override(entry) try: - current_ver, latest_ver = _run_config_check_fresh() + current_ver, latest_ver = check_config_version(raise_on_parse_error=True) if current_ver >= latest_ver: continue - _run_migrate_config_fresh(interactive=False, quiet=True) - after_ver, _ = _run_config_check_fresh() + migrate_config(interactive=False, quiet=True) + after_ver, _ = check_config_version(raise_on_parse_error=True) if after_ver > current_ver: migrated.append((entry.name, current_ver, after_ver)) except Exception as exc: @@ -168,20 +150,17 @@ def _check_and_apply_config_migration( See #91360. """ - from hermes_cli.update_cmd import ( - _migrate_sibling_profile_configs, _reload_config_modules, _run_config_check_fresh, - _run_migrate_config_fresh) + from hermes_cli.update_cmd import _migrate_sibling_profile_configs + from hermes_cli.config import check_config_version, migrate_config print() print("→ Checking configuration for new options...") - # Reload BEFORE any config reads so all checks use the updated code. - _reload_config_modules() from hermes_cli.config import get_missing_env_vars, get_missing_config_fields # A config-check failure must not break an otherwise-successful update. try: # Log, point at the manual command, and return. See #91360. missing_env = get_missing_env_vars(required_only=True) missing_config = get_missing_config_fields() - current_ver, latest_ver = _run_config_check_fresh() + current_ver, latest_ver = check_config_version(raise_on_parse_error=True) except Exception as exc: logger.debug("Config check during update failed: %s", exc) print(" ⚠️ Could not check config version.") @@ -198,7 +177,7 @@ def _check_and_apply_config_migration( print() print(f" ℹ Updating config format (v{current_ver} → v{latest_ver})…") try: - _mig_results = _run_migrate_config_fresh(interactive=False, quiet=True) + _mig_results = migrate_config(interactive=False, quiet=True) print(" ✓ Config format updated (no new settings to configure)") # quiet=True also mutes steps that RESET/REMOVE a setting; re-surface them so an # unattended update never silently changes config (config_added holds only mutations here). @@ -228,7 +207,7 @@ def _check_and_apply_config_migration( # Gateway/--yes/non-interactive can't prompt for API keys; still run the # non-interactive pass so defaults and version bumps land before the gateway restarts. unattended = gateway_mode or assume_yes or response == "auto" - results = _run_migrate_config_fresh(interactive=not unattended, quiet=False) + results = migrate_config(interactive=not unattended, quiet=False) if results["env_added"] or results["config_added"]: print() print("✓ Configuration updated!") diff --git a/hermes_cli/update_cmd_fleet.py b/hermes_cli/update_cmd_fleet.py index 2b49ed4b16..e8e38e653e 100644 --- a/hermes_cli/update_cmd_fleet.py +++ b/hermes_cli/update_cmd_fleet.py @@ -239,128 +239,13 @@ def _needs_sudo(scope: str) -> bool: ) -def _restart_systemd_gateway_units_best_effort(failed: list, listings) -> None: - """Best-effort ``systemctl restart`` of every hermes-gateway/serve unit.""" - answered = set() - for scope, scope_cmd, result in listings: - answered.add(scope) - if result.returncode != 0: - failed.append(f"systemd-{scope} (listing failed)") - continue - def process_unit(svc_name: str, _scope=scope, _cmd=scope_cmd) -> None: - manage_cmd = list(_cmd) + ["--no-ask-password"] - if _needs_sudo(_scope): - manage_cmd = ["sudo", "-n"] + manage_cmd - result = _systemctl_reset_and_restart(manage_cmd, svc_name, scope_cmd=_cmd) - if result.returncode != 0 or not _wait_for_service_active(_cmd, svc_name): - failed.append(svc_name) - - _for_each_systemd_gateway_unit( - result.stdout, - process_unit=process_unit, - on_unit_timeout=lambda svc_name, exc: failed.append(svc_name), - ) - # A timeout or missing executable is not an empty scope. - failed.extend(f"systemd-{scope} (listing unavailable)" for scope, _ in _SYSTEMD_SCOPES if scope not in answered) def _run_pending_fleet_restart() -> bool: - """Catch-up restart for gateways left on pre-update code. Never raises. - - True when all discovered targets recovered (or none exist); False if incomplete. - - See #95294. - """ - from hermes_cli.update_cmd import _m - print("→ Restarting gateways left on pre-update code...") - with suppress(Exception): - _m()._purge_stale_hermes_modules() - # Warn if legacy Hermes gateway unit files are still installed. When both hermes.service (from a - # pre-rename install) and the current hermes-gateway.service are enabled, they SIGTERM-fight for the - # same bot token (see PR #11909). Flagging here means every `hermes update` surfaces the issue until the - # user migrates. - try: - from hermes_cli.gateway import ( - find_gateway_pids, is_macos, is_windows, kill_gateway_processes, supports_systemd_services, - _wait_for_gateway_exit, - ) - except Exception as exc: - _warn_gateway_restart_phase_aborted(exc, None) - return False - - try: - pids = list(find_gateway_pids(all_profiles=True)) - except Exception as exc: - logger.debug("Pending fleet restart: gateway probe failed: %s", exc) - pids = None - - failed: list = [] - try: - # Snapshot before stopping: Restart=no units can disappear from list-units on a clean exit. - systemd_listings = list(_systemd_gateway_unit_listings()) if supports_systemd_services() else None - # Stop old processes before supervisor recovery, never its freshly verified workers. - if pids != []: - try: - leftover = list(find_gateway_pids(all_profiles=True)) - except Exception: - leftover = list(pids or []) - if leftover: - with _best_effort('Pending fleet restart: PID stop failed: %s'): - kill_gateway_processes(all_profiles=True) - _wait_for_gateway_exit(timeout=5.0, force_after=None) - # --- Systemd services (Linux) --- Discover all hermes-gateway* units (default + profiles) plus - # hermes-serve* units (the Desktop app's backend, #83438). - if systemd_listings is not None: - _restart_systemd_gateway_units_best_effort(failed, systemd_listings) - # --- Launchd services (macOS) --- Restart EVERY ai.hermes.gateway* LaunchAgent, not only the - # invoking profile's — parity with the systemd branch above (#41403). Per-label TimeoutExpired - # isolation happens inside. - if is_macos(): - try: - _restart_macos_launchd_gateways([], failed, 45.0, require_supervision=True) - except Exception as exc: - logger.debug("Pending fleet restart: launchd failed: %s", exc) - failed.append("launchd") - if is_windows(): - try: - from hermes_cli import gateway_windows - if gateway_windows.is_installed(): - gateway_windows.restart() - except Exception as exc: - logger.debug("Pending fleet restart: Windows failed: %s", exc) - failed.append("windows-gateway") - if failed: - _warn_incomplete_gateway_fleet_restart(failed) - return False - print(" ✓ Pending fleet restart completed.") - return True - except Exception as exc: - try: - surviving = list(find_gateway_pids(all_profiles=True)) - except Exception: - surviving = pids - _warn_gateway_restart_phase_aborted(exc, surviving) - return False - - -def _apply_pending_fleet_restart_catchup() -> None: - """On an already-up-to-date ``hermes update``, finish a skipped restart. - - No-op when nothing is pending; exits 1 on incomplete catch-up so automation - does not treat the fleet as healthy. - """ - from hermes_cli.update_cmd import _run_pending_fleet_restart - if not _pending_fleet_restart_needed(): - return - print() - _warn_pending_fleet_restart() - print("→ Running the pending fleet restart...") - if _run_pending_fleet_restart(): - _clear_fleet_restart_pending_marker() - return - print(" ⚠ Fleet restart incomplete. Recover with: hermes gateway restart") - sys.exit(1) + """Historical retry hook; new retries use the ordinary completion owner.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _systemctl(cmd: list, *, timeout: float): @@ -1182,10 +1067,6 @@ def _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode: bool): # already-restarted units to ``_refresh_dashboard_after_update`` (review on #83595). restarted_scoped_units: set = set() - # Purge stale cached Hermes modules FIRST: the import below loads new gateway - # source into this pre-update interpreter, and a cached sibling missing a - # symbol the new source expects would ImportError and abort the whole phase. - _m()._purge_stale_hermes_modules() try: # Every gateway helper the phase needs is imported up front so a broken gateway # module aborts into recovery BEFORE any unit is touched. diff --git a/hermes_cli/update_cmd_maint.py b/hermes_cli/update_cmd_maint.py index d5e5b2d11c..c14a3bf405 100644 --- a/hermes_cli/update_cmd_maint.py +++ b/hermes_cli/update_cmd_maint.py @@ -5,7 +5,6 @@ still resolves/monkeypatches. Origin helpers are imported lazily per function (n test patches on ``update_cmd`` stay effective). """ -import importlib import logging from contextlib import suppress import os @@ -24,37 +23,10 @@ logger = logging.getLogger("hermes_cli.update_cmd") def _prepare_updated_checkout(project_root: Path, *, desktop: bool) -> None: - """PM publishes dependencies before the shared builders consume the checkout.""" - import pm + """Historical updater hook: never complete inside the pre-swap interpreter.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) - pm.sync_venv(explicit=True, project_root=project_root) - from hermes_cli.venv_sync import publish_launchers - - publish_launchers(project_root) - from hermes_cli.runtime_paths import activation_environment, selected_venv - - # The updater still holds pre-pull imports. Build only in the newly selected Python. - command = [str(venv_python_path(selected_venv(project_root))), - "-m", "hermes_cli.source_build", "--source", str(project_root)] - if desktop: - command.append("--desktop") - subprocess.run(command, cwd=project_root, env=activation_environment(project_root), check=True) - - -#: Package prefixes whose cached modules go stale when the checkout changes under this -#: process; purged (not reloaded) so any LATER import chain resolves against fresh source. -_STALE_PURGE_PREFIXES = "hermes_cli", "gateway", "tools", "tui_gateway", "agent" - -#: Modules EXECUTING the update survive the purge: evicting them buys nothing (running frames -#: keep them alive) and reloading them mid-flight is the one genuinely unsafe move. -_STALE_PURGE_PROTECTED = frozenset({"hermes_cli", "hermes_cli.main", "hermes_cli.hermes_logging"}) - -#: The updater's own module family (``update_cmd*``, ``update_receipt``, ``update_inventory``, -#: ``update_lock``, ...) is protected as a prefix: these hold per-run state — the open receipt -#: singleton, the pre-update plan's ``RuntimeRecord`` class identity, the lock — and evicting -#: one swaps in a fresh module whose ``_current`` is None (receipt silently never written) or -#: whose dataclass fails every ``isinstance`` against the plan built before the purge. -_STALE_PURGE_PROTECTED_PREFIX = "hermes_cli.update_" _PRE_UPDATE_SNAPSHOT_KEEP = 1 @@ -73,41 +45,10 @@ def _load_updates_cfg() -> dict: return updates if isinstance(updates, dict) else {} -def _reload_modules(names, *, modules, log) -> None: - """``importlib.reload`` each module of *names* cached in *modules*; failures go to *log*.""" - importlib.invalidate_caches() - for module_name in names: - module = modules.get(module_name) - if module is None: - continue - try: - importlib.reload(module) - except Exception as exc: - log(module_name, exc) - - def _purge_stale_hermes_modules() -> None: - """Evict every cached Hermes module after the checkout changed in-place. Never raises. - - The update runs in the pre-pull process; later phases lazily import NEW source into an OLD - ``sys.modules`` world and die when new code references a symbol missing from a cached - module. Purging (unlike reload) only drops the ``sys.modules`` entry — running frames keep - their module objects — so later imports rebuild a self-consistent graph from the new tree. - """ - from hermes_cli.update_cmd import _m - with _best_effort('Could not purge stale Hermes modules: %s'): - importlib.invalidate_caches() - modules = _m().sys.modules - purged = [ - name for name in list(modules) - if name not in _STALE_PURGE_PROTECTED - and not name.startswith(_STALE_PURGE_PROTECTED_PREFIX) - # Root-package check: startswith() alone also matches unrelated ``gateway_foo``. - and name.split(".", 1)[0] in _STALE_PURGE_PREFIXES - and modules.pop(name, None) is not None - ] - if purged: - logger.debug("Purged %d stale Hermes module(s) after checkout update", len(purged)) + """Historical updater hook; module-graph surgery cannot complete an update.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _reload_updated_runtime_modules() -> None: @@ -296,25 +237,9 @@ def _format_time_ago(iso_ts: str) -> str: def _reload_process_scan_modules() -> None: - """Reload the process-scan modules, dependency-first, so ``dashboard_procs`` binds against a - fresh ``_subprocess_compat``: cleanup runs in the PRE-update process and a symbol the update - added would otherwise ImportError after the code update succeeded. Called from the cleanup - entry point so every caller (git path, ZIP fallback) is covered. - - ``_refresh_dashboard_after_update`` runs in the PRE-update Python process, but - ``_scan_dashboard_processes`` does a function-level ``from hermes_cli._subprocess_compat import - bounded_probe_run``. If the update added a new symbol to ``_subprocess_compat`` (as #87134 did with - ``bounded_probe_run``), the cached OLD module object doesn't have it and the cleanup step crashes with - ImportError — after the code update itself already succeeded. - """ - _reload_modules( - ("hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs"), - modules=sys.modules, - # warning, not debug: a failed reload surfaces as ImportError seconds later. - log=lambda name, exc: logger.warning( - "Could not reload %s for post-update cleanup: %s", name, exc - ), - ) + """Historical updater hook; scans now run only in fresh completion Python.""" + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) def _finish_dashboard_update_cleanup( @@ -334,8 +259,7 @@ def _refresh_dashboard_after_update(*, already_restarted_units: set[str] | None See #83595. """ - from hermes_cli.update_cmd import _m, _reload_process_scan_modules - _reload_process_scan_modules() + from hermes_cli.update_cmd import _m stop_result = _m()._kill_stale_dashboard_processes( restart_managed=True, already_restarted_units=already_restarted_units @@ -393,12 +317,9 @@ def _update_complete_message(pre_version: str | None) -> str: def _post_update_sqlite_runtime_status(): """Return whether the interpreter used after update has safe SQLite.""" - from hermes_cli.update_cmd import _m - from hermes_constants import project_venv_dir from hermes_cli.sqlite_runtime import probe_sqlite_runtime - venv_dir = project_venv_dir(_m().PROJECT_ROOT) - python = (venv_python_path(venv_dir, windows=_m()._is_windows()) if venv_dir is not None else Path(sys.executable)) - info = probe_sqlite_runtime(python) + # Completion already runs on PM's selected Python, not the obsolete repo venv. + info = probe_sqlite_runtime(Path(sys.executable)) return info is not None and not info.wal_reset_vulnerable, info @@ -628,43 +549,10 @@ def _ensure_fhs_path_guard() -> None: def _ensure_acp_launcher() -> None: - r"""Self-heal a ``hermes-acp`` launcher next to ``hermes`` (mirrors install.sh): ACP hosts - resolve it on the login-shell PATH but the console script lives in the venv. The shim - delegates to the sibling ``hermes acp``, correct for every layout. - - No-op on Windows (install.ps1 stages launchers into ``$HermesHome\bin``, never - ``venv\Scripts`` which would shadow the user's python; launcher repair lives in - _install_repair) and where it already exists. Unwritable dirs are skipped. Idempotent. - - ``/usr/local/bin`` as non-root) are skipped silently. See #83797. - """ + """Historical export; launcher policy belongs to the launcher owner.""" + from hermes_cli import _launchers from hermes_cli.update_cmd import _m - if _m().sys.platform == "win32": - return - for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")): - hermes_cmd = bin_dir / "hermes" - acp_cmd = bin_dir / "hermes-acp" - try: - if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()): - continue - # is_symlink() catches broken symlinks exists() misses; never follow-and-overwrite. - # Already present — a console script (pip/pipx install), an earlier shim, or a symlink. - # is_symlink() catches broken symlinks that exists() would miss; never follow-and-overwrite (the - # #21454 failure mode). - if acp_cmd.exists() or acp_cmd.is_symlink(): - continue - shim = ( - "#!/usr/bin/env bash\n" - "# Hermes Agent — ACP launcher (written by `hermes update`).\n" - "# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n" - "# command name on the login-shell PATH.\n" - f'exec "{hermes_cmd}" acp "$@"\n' - ) - acp_cmd.write_text(shim, encoding="utf-8") - acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755) - except OSError: - continue - print(f" ✓ Installed hermes-acp launcher → {acp_cmd}") + _launchers.expose_cli(_m().PROJECT_ROOT) _BACKUP_MODE_ALIASES = { @@ -934,6 +822,7 @@ def _print_post_update_notices_and_self_heals() -> None: """Best-effort notices (FTS optimize, curator) and self-heals (FHS PATH, ACP launcher, Windows bin launchers, cua-driver refresh) that run after the summary.""" from hermes_cli.update_cmd import _m, _print_curator_first_run_notice, _print_curator_recent_run_notice + from hermes_cli import _launchers def _migrate_windows_bin_path() -> None: # Windows launchers into the managed bin dir: in-checkout launchers were swept by the @@ -947,7 +836,7 @@ def _print_post_update_notices_and_self_heals() -> None: ('Curator first-run notice failed: %s', _print_curator_first_run_notice), ('Curator recent-run notice failed: %s', _print_curator_recent_run_notice), ('FHS PATH guard check failed: %s', _ensure_fhs_path_guard), - ('hermes-acp launcher self-heal failed: %s', _ensure_acp_launcher), + ('CLI launcher exposure failed: %s', lambda: _launchers.expose_cli(_m().PROJECT_ROOT)), ('Windows bin launcher migration failed: %s', _migrate_windows_bin_path), ('cua-driver refresh failed: %s', _refresh_cua_driver_after_update), ('Plugin compat notice failed: %s', _print_plugin_compat_notice), @@ -958,7 +847,7 @@ def _print_post_update_notices_and_self_heals() -> None: def _run_post_update_maintenance( *, assume_yes, gateway_mode, pre_update_snapshot_id, had_desktop_app_before_update, - pre_update_version, + pre_update_version, completion_message=None, ) -> bool: """Post-build housekeeping and completion, returning the SQLite runtime verdict. @@ -1011,7 +900,7 @@ def _run_post_update_maintenance( ) print() - update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version)) + update_complete = _print_verified_update_completion(completion_message or _update_complete_message(pre_update_version)) _print_post_update_notices_and_self_heals() return update_complete diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index 20ea9b056b..d61e2ff127 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -319,24 +319,13 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None: def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, - target_sha: str | None = None, target_repository: str | None = None) -> bool: + target_sha: str | None = None, target_repository: str | None = None, + completion_request=None) -> bool: """Update via ZIP when Windows git file I/O fails; dependency/build failures propagate. A supplied commit keeps the archive on the target selected before Git failed. """ - from hermes_cli.update_cmd import ( - _m, - _print_curator_first_run_notice, - _print_curator_recent_run_notice, - _read_project_version, - _verify_and_restore_state_dbs_post_update, - ) - from hermes_cli.update_cmd_maint import ( - _prepare_updated_checkout, _refresh_dashboard_after_update, - _print_verified_update_completion, _update_complete_message) - from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report - from hermes_cli.update_cmd_maint import _sweep_bytecode_after_update - pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line + from hermes_cli.update_cmd import _m, _complete_source_update # The static archive would silently ignore --branch — the exact silent-divergence bug it exists to # prevent. Refuse rather than lie. branch = _m()._resolve_update_branch(args) @@ -350,6 +339,10 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, ) _m().sys.exit(1) _abort_zip_update_if_dirty_tree() + # Older callers lack the snapshot/receipt/lifecycle handoff. Refuse before swap. + if completion_request is None: + from hermes_cli._old_updater import stop_for_relaunch + stop_for_relaunch(incomplete=True) if target_sha is not None and not re.fullmatch(r"[0-9a-f]{40}", target_sha): raise ValueError("ZIP update requires an exact full commit SHA") ref = target_sha if target_sha is not None else f"refs/heads/{branch}" @@ -358,27 +351,6 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, or any(part in (".", "..") for part in repository.split("/"))): raise ValueError("ZIP update requires a GitHub owner/repository") _download_and_swap_zip(branch, f"https://github.com/{repository}/archive/{ref}.zip") - _sweep_bytecode_after_update(branch) - _prepare_updated_checkout(_m().PROJECT_ROOT, desktop=had_desktop_app_before_update) - with suppress(Exception): - print("→ Syncing bundled skills...") - _print_bundled_skills_sync_report() - # Seed the model-catalog disk cache from the fresh checkout (same rationale as _cmd_update_impl). Non-fatal. - with _best_effort('Model catalog seed during zip update failed: %s'): - from hermes_cli.model_catalog import seed_cache_from_checkout - if seed_cache_from_checkout(_m().PROJECT_ROOT): - print(" ✓ Model catalog cache refreshed from checkout") - # state.db integrity guard: root home AND every sibling profile, each auto-restored from its own snapshot. - with _best_effort('Post-update state.db integrity check (zip path) failed: %s'): - # See #97994. - _verify_and_restore_state_dbs_post_update() - update_complete = _print_verified_update_completion(_update_complete_message(pre_update_version)) - with _best_effort('Curator first-run notice failed: %s'): - _print_curator_first_run_notice() - with _best_effort('Curator recent-run notice failed: %s'): - _print_curator_recent_run_notice() - _refresh_dashboard_after_update() - with _best_effort('Update receipt finalize (zip path) failed: %s'): - from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("success" if update_complete else "partial") - return update_complete + completion_request["expected_sha"] = target_sha + _complete_source_update(completion_request) + return True diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py new file mode 100644 index 0000000000..65b44bfb3e --- /dev/null +++ b/hermes_cli/update_completion.py @@ -0,0 +1,265 @@ +"""Fresh-checkout source update completion and its stdlib-only parent transport. + +Imported before a swap; executed by path from the selected tree afterward. The +parent never imports application helpers from the replacement checkout. +""" + +from __future__ import annotations + +import codecs +import json +import os +import signal +from pathlib import Path +import subprocess +import sys +import tempfile + + +def _write_json(path: Path, data: dict) -> None: + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(data), encoding="utf-8") + temporary.replace(path) + + +def _exit_status(code: int) -> int: + return code if code >= 0 else 128 - code + + +def _failed_result(request: dict, result_path: Path, code: int) -> int: + code = _exit_status(code) or 1 + _write_json(result_path, { + "schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code, + "receipt": None, "windows_resume": None, "pm_receipt": request.get("pm_receipt"), + }) + return code + + +def run_completion(request: dict) -> dict: + """Wait for new code; zero exit without a correlated terminal result fails closed.""" + root = Path(request["source"]) + env = dict(os.environ, HERMES_HOME=request["home"], PYTHONUNBUFFERED="1") + for key in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"): + env.pop(key, None) + with tempfile.TemporaryDirectory(prefix="hermes-completion-") as directory: + request_path = Path(directory) / "request.json" + result_path = Path(directory) / "result.json" + request = {**request, "stdout_isatty": sys.stdout.isatty()} + request["bytecode_cache"] = str(Path(directory) / "bytecode") + _write_json(request_path, request) + command = [sys.executable, "-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}", + str(root / "hermes_cli/update_completion.py"), + str(request_path), str(result_path)] + proc = subprocess.Popen( + command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + **({"start_new_session": True} if os.name == "posix" else + {"creationflags": subprocess.CREATE_NO_WINDOW})) + decoder = codecs.getincrementaldecoder("utf-8")("replace") + try: + while True: + chunk = proc.stdout.read1(8192) + sys.stdout.write(decoder.decode(chunk, final=not chunk)) + sys.stdout.flush() + if not chunk: + break + code = proc.wait() + except BaseException: + # This group/retained process handle belongs exclusively to us. + # Stop descendants BEFORE releasing the command's update lock. + if os.name == "posix": + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + else: + subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW) + proc.kill() + proc.wait() + raise + finally: + proc.stdout.close() + code = _exit_status(code) + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + if result["schema"] != 1 or result["update_id"] != request["receipt"]["update_id"]: + raise ValueError("completion response identity mismatch") + if result["exit_code"] != code: + raise ValueError("completion response disagrees with process exit") + receipt = result.get("receipt") + if receipt is not None and ( + receipt.get("update_id") != request["receipt"]["update_id"] + or not receipt.get("finished_at") + or (code == 0) != (receipt.get("outcome") == "success") + ): + raise ValueError("completion receipt does not attest this outcome") + if code == 0 and receipt is None: + raise ValueError("completion did not publish a terminal receipt") + except (OSError, ValueError, KeyError, TypeError) as exc: + print(f"✗ Source update completion did not finish: {exc}") + return {"exit_code": code or 1, "receipt": None, "windows_resume": None} + return result + + +def _resume_receipt(data: dict) -> None: + from hermes_cli import update_receipt + + # Hydrate the existing run, not a new receipt with a new identity/pre-update probe. + receipt = object.__new__(update_receipt.UpdateReceipt) + receipt.data = data + receipt.correlation_id = data["update_id"] + receipt.current_token = update_receipt._current.set(receipt) + + +def _read_terminal_receipt(request: dict) -> dict | None: + directory = Path(request["home"]) / "logs/update_receipts" + # Never latest.json: another profile/context may have finalized more recently. + for path in directory.glob(f"update_*_{request['receipt']['update_id']}.json"): + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("update_id") == request["receipt"]["update_id"] and data.get("finished_at"): + return data + return None + + +def _prepare(request: dict, request_path: Path, result_path: Path) -> int: + import pm + from pm import receipt + from hermes_cli.runtime_paths import activation_environment, selected_venv + from hermes_constants import venv_python_path + + root = Path(request["source"]) + update_id = request["receipt"]["update_id"] + with receipt.worker_context(update_id): + try: + pm.sync_venv(explicit=True, project_root=root) + finally: + request["pm_receipt"] = receipt.last_for_update(update_id) + _write_json(request_path, request) + command = [str(venv_python_path(selected_venv(root))), + "-I", "-S", "-X", f"pycache_prefix={request['bytecode_cache']}", + str(root / "hermes_cli/update_completion.py"), + str(request_path), str(result_path), "--prepared"] + # A second interpreter is mandatory: PM may have selected a different Python + # and dependency graph. No application maintenance runs in this bootstrap. + code = _exit_status(subprocess.call(command, cwd=root, env=activation_environment(root))) + if not result_path.exists(): + return _failed_result(request, result_path, code) + return code + + +def _complete_selected(request: dict) -> None: + from hermes_cli import main, update_cmd, update_cmd_config + from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan + from hermes_cli.update_cmd_maint import _run_post_update_maintenance + from hermes_cli.source_build import build_update_products + from hermes_cli.venv_sync import publish_launchers + + root = Path(request["source"]) + main.PROJECT_ROOT = root + update_cmd_config._LAST_SIBLING_SNAPSHOTS = request["sibling_snapshots"] + plan_data = request["plan"] + plan = None if plan_data is None else UpdatePlan(**{ + **plan_data, "runtimes": [RuntimeRecord(**row) for row in plan_data.get("runtimes", [])]}) + update_cmd._sweep_bytecode_after_update(request["branch"]) + publish_launchers(root) + build_update_products(root, desktop=request["desktop"]) + if not request.get("completion_message"): + print("\n✓ Code updated!") + complete = _run_post_update_maintenance( + assume_yes=request["assume_yes"], gateway_mode=request["gateway_mode"], + pre_update_snapshot_id=request["snapshot_id"], + had_desktop_app_before_update=request["desktop"], pre_update_version=request["pre_update_version"], + completion_message=request.get("completion_message")) + # systemctl's KillMode=mixed fallback can kill this whole cgroup. Publish the + # gateway watcher's status BEFORE that operation, and demote on later failure. + if request["gateway_mode"]: + update_cmd._write_gateway_update_exit_code(complete) + restart = update_cmd._restart_gateway_fleet_after_update(plan, request["gateway_mode"]) + update_cmd._resume_windows_gateways_and_merge_outcome(restart, request["windows_resume"], request["gateway_mode"]) + update_cmd._verify_fleet_after_update( + restart, _pre_update_plan=plan, _windows_gateway_resume=request["windows_resume"], update_complete=complete) + + +class _ForwardedOutput: + """The parent's pipe preserves its terminal's prompt policy and log mirror.""" + + def __init__(self, stream, isatty: bool): + self.stream, self.terminal = stream, isatty + + def isatty(self): + return self.terminal + + def __getattr__(self, name): + return getattr(self.stream, name) + + +def _finish(request: dict, result_path: Path) -> int: + from hermes_cli import update_receipt + from pm.receipt import accept_worker_receipt + + _resume_receipt(request["receipt"]) + accept_worker_receipt(request.get("pm_receipt"), request["receipt"]["update_id"]) + code, reason = 0, "source update completion" + try: + _complete_selected(request) + except SystemExit as exc: + code = _exit_status(exc.code) if isinstance(exc.code, int) else 1 + reason = f"completion exited {code}" + except BaseException as exc: + code = _exit_status(exc.returncode) if isinstance(exc, subprocess.CalledProcessError) else 1 + reason = f"{type(exc).__name__}: {exc}" + print(f"✗ Source update completion failed: {reason}") + finally: + if code and request["gateway_mode"]: + from hermes_cli.update_cmd import _write_gateway_update_exit_code + _write_gateway_update_exit_code(False) + # The new interpreter owns recovery too. The original parent's atexit + # token is updated from the response; it acts only if this process dies. + try: + from hermes_cli.update_cmd import _resume_windows_gateways_after_update + _resume_windows_gateways_after_update(request["windows_resume"]) + except Exception as exc: + code, reason = 1, f"Windows gateway recovery failed: {exc}" + print(f"✗ {reason}") + update_receipt.finalize_pending_update_receipt(code, reason) + terminal_receipt = _read_terminal_receipt(request) + if not terminal_receipt: + code = code or 1 + _write_json(result_path, { + "schema": 1, "update_id": request["receipt"]["update_id"], "exit_code": code, + "receipt": terminal_receipt, "windows_resume": request["windows_resume"], + }) + return code + + +def main() -> int: + request_path, result_path = map(Path, sys.argv[1:3]) + request = json.loads(request_path.read_text(encoding="utf-8")) + if request["schema"] != 1: + raise ValueError("unsupported source completion request") + root = Path(__file__).resolve().parents[1] + if root != Path(request["source"]).resolve(): + raise ValueError("completion checkout does not match request") + # -I deliberately ignores inherited PYTHONPATH during PM preparation. + sys.path.insert(0, str(root)) + sys.stdout = _ForwardedOutput(sys.stdout, request.get("stdout_isatty", False)) + if "--prepared" in sys.argv[3:]: + # Claim the selected generation's lease and process its .pth files only + # after PM selection, before importing any application dependencies. + from hermes_cli.runtime_paths import activate_dependencies + activate_dependencies(root) + return _finish(request, result_path) + try: + return _prepare(request, request_path, result_path) + except BaseException as exc: + # PM failed before application dependencies were ready. Leave the parent + # receipt and paused-gateway obligation intact for boundary recovery. + print(f"✗ Source update preparation failed: {exc}") + code = exc.returncode if isinstance(exc, subprocess.CalledProcessError) else 1 + return _failed_result(request, result_path, code) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json index d84313a3d2..07a4a1857f 100644 --- a/tests/compat/old_updater_surface.json +++ b/tests/compat/old_updater_surface.json @@ -1,5 +1,5 @@ { - "_comment": "Generated by scripts/audit-old-updater-imports.py --freeze. Names an already-running `hermes update` loads from the NEW tree after the checkout swap. Deleting a bare name bricks every release that loads it, mid-update, on a half-new tree. Regenerate after changing the update flow; never hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review. Current-tree requirements refreshed with audit_tree and unioned without removing any frozen historical requirement; history_ref is unchanged.", + "_comment": "Baseline generated by scripts/audit-old-updater-imports.py --freeze; additively extended with the same audit engine over complete trees of every newly reachable shipped commit and the current tree. No baseline requirement was removed. Names an already-running `hermes update` loads from the NEW tree after the checkout swap. Deleting a bare name bricks every release that loads it, mid-update, on a half-new tree. Regenerate after changing the update flow; never hand-trim. History enumeration is complete; static call-graph limits and unresolved_dynamic still require manual review.", "stats": { "mode": "union", "history": { @@ -307,12 +307,297 @@ "blobs_mentioning_entrypoints": 2443 }, "coverage": "All reachable commits inventoried; distinct selected path/blob versions analyzed. Commit evidence lists version witnesses, not every unchanged descendant.", - "history_ref": "1021a0325696e9070e6659f95fcd84c3e7e114df", + "history_ref": "d595e636c83aa0b9606d4e914e1140ae9c796897", "complete_history": true, - "commits": 33720, + "commits": 34026, "roots": [ "21d80ca68346dfdb8d3556015a723a9217f8566f" - ] + ], + "incremental_extension": { + "base_ref": "1021a0325696e9070e6659f95fcd84c3e7e114df", + "tip": "d595e636c83aa0b9606d4e914e1140ae9c796897", + "newly_reachable_commits": 306, + "coverage": "Complete trees for every newly reachable commit plus the baseline tree; union with the complete frozen baseline.", + "audit": { + "files_analyzed": [ + "agent/curator.py", + "agent/deadline.py", + "agent/delegation_context.py", + "agent/memory_provider.py", + "agent/redact.py", + "agent/retry_utils.py", + "agent/secret_scope.py", + "agent/secret_sources/base.py", + "agent/secret_sources/registry.py", + "agent/skill_utils.py", + "agent/terminal_env_registry.py", + "gateway/config.py", + "gateway/config_env.py", + "gateway/config_loader.py", + "gateway/control_socket.py", + "gateway/cwd_placeholder.py", + "gateway/lifecycle_ledger.py", + "gateway/platform_registry.py", + "gateway/platforms/_shared.py", + "gateway/profile_routing.py", + "gateway/restart.py", + "gateway/run.py", + "gateway/session_context.py", + "gateway/shutdown_forensics.py", + "gateway/shutdown_watchdog.py", + "gateway/status.py", + "hermes_cli/_early_recovery.py", + "hermes_cli/_install_repair.py", + "hermes_cli/_parser.py", + "hermes_cli/_scan_venv_blockers.py", + "hermes_cli/_subprocess_compat.py", + "hermes_cli/agent_plugins.py", + "hermes_cli/auth.py", + "hermes_cli/backup.py", + "hermes_cli/banner.py", + "hermes_cli/build_info.py", + "hermes_cli/cli_output.py", + "hermes_cli/colors.py", + "hermes_cli/config.py", + "hermes_cli/config_backups.py", + "hermes_cli/config_defaults.py", + "hermes_cli/config_home.py", + "hermes_cli/config_migrations.py", + "hermes_cli/curses_ui.py", + "hermes_cli/default_soul.py", + "hermes_cli/env_loader.py", + "hermes_cli/gateway.py", + "hermes_cli/gateway_migrate.py", + "hermes_cli/gateway_multiplex_served.py", + "hermes_cli/gateway_windows.py", + "hermes_cli/git_credentials.py", + "hermes_cli/gitlock.py", + "hermes_cli/image_provenance.py", + "hermes_cli/macos_tcc_anchor.py", + "hermes_cli/main.py", + "hermes_cli/main_dashboard.py", + "hermes_cli/main_install_repair.py", + "hermes_cli/main_tui_launch.py", + "hermes_cli/main_web_build.py", + "hermes_cli/managed_scope.py", + "hermes_cli/managed_uv.py", + "hermes_cli/mcp_security.py", + "hermes_cli/memory_setup.py", + "hermes_cli/model_catalog.py", + "hermes_cli/npm_engine.py", + "hermes_cli/plugin_capabilities.py", + "hermes_cli/plugin_catalog.py", + "hermes_cli/plugin_compat.py", + "hermes_cli/plugins.py", + "hermes_cli/plugins_cmd.py", + "hermes_cli/plugins_cmd_catalog.py", + "hermes_cli/plugins_discovery.py", + "hermes_cli/plugins_ledger.py", + "hermes_cli/plugins_loader.py", + "hermes_cli/plugins_manifest.py", + "hermes_cli/process_identity.py", + "hermes_cli/profiles.py", + "hermes_cli/psutil_android.py", + "hermes_cli/pt_input_extras.py", + "hermes_cli/relay_plugin_cutover.py", + "hermes_cli/resource_limits.py", + "hermes_cli/secret_prompt.py", + "hermes_cli/service_manager.py", + "hermes_cli/setup.py", + "hermes_cli/sizefmt.py", + "hermes_cli/sqlite_runtime.py", + "hermes_cli/sqlite_safe_read.py", + "hermes_cli/subcommands/update.py", + "hermes_cli/tools_config.py", + "hermes_cli/tools_config_cua.py", + "hermes_cli/tools_config_post_setup.py", + "hermes_cli/toolset_scope.py", + "hermes_cli/toolset_validation.py", + "hermes_cli/update_abort_recovery.py", + "hermes_cli/update_cmd.py", + "hermes_cli/update_cmd_common.py", + "hermes_cli/update_cmd_config.py", + "hermes_cli/update_cmd_deps.py", + "hermes_cli/update_cmd_fleet.py", + "hermes_cli/update_cmd_git.py", + "hermes_cli/update_cmd_maint.py", + "hermes_cli/update_cmd_stash.py", + "hermes_cli/update_cmd_windows.py", + "hermes_cli/update_cmd_zip.py", + "hermes_cli/update_contract.py", + "hermes_cli/update_inventory.py", + "hermes_cli/update_lock.py", + "hermes_cli/update_receipt.py", + "hermes_constants.py", + "hermes_state.py", + "plugins/memory/__init__.py", + "plugins/memory/honcho/cli.py", + "plugins/memory/honcho/client.py", + "plugins/memory/honcho/client_cache.py", + "plugins/memory/honcho/oauth.py", + "plugins/plugin_loader.py", + "plugins/plugin_utils.py", + "tools/browser_tool.py", + "tools/browser_tool_install.py", + "tools/browser_tool_lifecycle.py", + "tools/browser_tool_origin.py", + "tools/computer_use/cua_backend.py", + "tools/computer_use/cua_backend_driver.py", + "tools/env_passthrough.py", + "tools/environments/local.py", + "tools/environments/local_env_policy.py", + "tools/environments/local_pythonpath.py", + "tools/lazy_deps.py", + "tools/plugin_guard.py", + "tools/skill_usage.py", + "tools/skills_guard.py", + "tools/skills_sync.py", + "tools/skills_sync_optional.py", + "tools/terminal_scope.py", + "utils.py" + ], + "entrypoint_paths": [ + "hermes_cli/main.py", + "hermes_cli/plugins_cmd.py", + "hermes_cli/update_cmd.py", + "hermes_cli/update_cmd_zip.py" + ], + "files_with_reachable_functions": [ + "agent/curator.py", + "agent/deadline.py", + "agent/delegation_context.py", + "agent/redact.py", + "agent/retry_utils.py", + "agent/secret_scope.py", + "agent/secret_sources/base.py", + "agent/secret_sources/registry.py", + "agent/skill_utils.py", + "agent/terminal_env_registry.py", + "gateway/config.py", + "gateway/config_env.py", + "gateway/config_loader.py", + "gateway/control_socket.py", + "gateway/cwd_placeholder.py", + "gateway/lifecycle_ledger.py", + "gateway/platforms/_shared.py", + "gateway/profile_routing.py", + "gateway/restart.py", + "gateway/run.py", + "gateway/session_context.py", + "gateway/shutdown_forensics.py", + "gateway/shutdown_watchdog.py", + "gateway/status.py", + "hermes_cli/_early_recovery.py", + "hermes_cli/_install_repair.py", + "hermes_cli/_parser.py", + "hermes_cli/_scan_venv_blockers.py", + "hermes_cli/_subprocess_compat.py", + "hermes_cli/agent_plugins.py", + "hermes_cli/auth.py", + "hermes_cli/backup.py", + "hermes_cli/banner.py", + "hermes_cli/build_info.py", + "hermes_cli/cli_output.py", + "hermes_cli/colors.py", + "hermes_cli/config.py", + "hermes_cli/config_backups.py", + "hermes_cli/config_home.py", + "hermes_cli/config_migrations.py", + "hermes_cli/curses_ui.py", + "hermes_cli/default_soul.py", + "hermes_cli/env_loader.py", + "hermes_cli/gateway.py", + "hermes_cli/gateway_migrate.py", + "hermes_cli/gateway_multiplex_served.py", + "hermes_cli/gateway_windows.py", + "hermes_cli/git_credentials.py", + "hermes_cli/gitlock.py", + "hermes_cli/image_provenance.py", + "hermes_cli/macos_tcc_anchor.py", + "hermes_cli/main.py", + "hermes_cli/main_dashboard.py", + "hermes_cli/main_install_repair.py", + "hermes_cli/main_tui_launch.py", + "hermes_cli/main_web_build.py", + "hermes_cli/managed_scope.py", + "hermes_cli/managed_uv.py", + "hermes_cli/mcp_security.py", + "hermes_cli/memory_setup.py", + "hermes_cli/model_catalog.py", + "hermes_cli/npm_engine.py", + "hermes_cli/plugin_capabilities.py", + "hermes_cli/plugin_catalog.py", + "hermes_cli/plugin_compat.py", + "hermes_cli/plugins.py", + "hermes_cli/plugins_cmd.py", + "hermes_cli/plugins_cmd_catalog.py", + "hermes_cli/plugins_discovery.py", + "hermes_cli/plugins_ledger.py", + "hermes_cli/plugins_loader.py", + "hermes_cli/plugins_manifest.py", + "hermes_cli/process_identity.py", + "hermes_cli/profiles.py", + "hermes_cli/psutil_android.py", + "hermes_cli/pt_input_extras.py", + "hermes_cli/relay_plugin_cutover.py", + "hermes_cli/resource_limits.py", + "hermes_cli/secret_prompt.py", + "hermes_cli/service_manager.py", + "hermes_cli/setup.py", + "hermes_cli/sizefmt.py", + "hermes_cli/sqlite_runtime.py", + "hermes_cli/sqlite_safe_read.py", + "hermes_cli/tools_config_cua.py", + "hermes_cli/tools_config_post_setup.py", + "hermes_cli/toolset_scope.py", + "hermes_cli/toolset_validation.py", + "hermes_cli/update_abort_recovery.py", + "hermes_cli/update_cmd.py", + "hermes_cli/update_cmd_common.py", + "hermes_cli/update_cmd_config.py", + "hermes_cli/update_cmd_deps.py", + "hermes_cli/update_cmd_fleet.py", + "hermes_cli/update_cmd_git.py", + "hermes_cli/update_cmd_maint.py", + "hermes_cli/update_cmd_stash.py", + "hermes_cli/update_cmd_windows.py", + "hermes_cli/update_cmd_zip.py", + "hermes_cli/update_contract.py", + "hermes_cli/update_inventory.py", + "hermes_cli/update_lock.py", + "hermes_cli/update_receipt.py", + "hermes_constants.py", + "plugins/memory/__init__.py", + "plugins/memory/honcho/cli.py", + "plugins/memory/honcho/client.py", + "plugins/memory/honcho/client_cache.py", + "plugins/memory/honcho/oauth.py", + "plugins/plugin_loader.py", + "tools/browser_tool_install.py", + "tools/browser_tool_lifecycle.py", + "tools/browser_tool_origin.py", + "tools/computer_use/cua_backend.py", + "tools/computer_use/cua_backend_driver.py", + "tools/env_passthrough.py", + "tools/environments/local.py", + "tools/environments/local_env_policy.py", + "tools/environments/local_pythonpath.py", + "tools/lazy_deps.py", + "tools/plugin_guard.py", + "tools/skill_usage.py", + "tools/skills_guard.py", + "tools/skills_sync.py", + "tools/skills_sync_optional.py", + "tools/terminal_scope.py", + "utils.py" + ], + "commits_with_audited_changes": 307, + "revisions_read": 41361, + "distinct_file_versions": 231, + "analysis_passes": 331, + "versions_prepared": 231 + } + } }, "tree": { "files_analyzed": [ @@ -430,6 +715,7 @@ "hermes_cli/update_cmd_validation.py", "hermes_cli/update_cmd_windows.py", "hermes_cli/update_cmd_zip.py", + "hermes_cli/update_completion.py", "hermes_cli/update_contract.py", "hermes_cli/update_inventory.py", "hermes_cli/update_lock.py", @@ -606,6 +892,7 @@ "hermes_cli/update_cmd_validation.py", "hermes_cli/update_cmd_windows.py", "hermes_cli/update_cmd_zip.py", + "hermes_cli/update_completion.py", "hermes_cli/update_contract.py", "hermes_cli/update_inventory.py", "hermes_cli/update_lock.py", @@ -662,12 +949,13 @@ "utils.py" ], "commits_with_audited_changes": 1, - "revisions_read": 172, - "distinct_file_versions": 172, - "analysis_passes": 214, - "versions_prepared": 172, + "revisions_read": 173, + "distinct_file_versions": 173, + "analysis_passes": 215, + "versions_prepared": 173, "mode": "tree" - } + }, + "completion_union": "Preserved complete checked-in history and prior tree edges; unioned fresh current-tree audit." }, "unresolved_dynamic": [ "hermes_cli/config.py: module object hermes_cli.managed_scope requires manual call-graph review", @@ -1005,6 +1293,7 @@ "hermes_cli.update_cmd::_check_and_apply_config_migration", "hermes_cli.update_cmd::_cmd_update_check", "hermes_cli.update_cmd::_cmd_update_impl", + "hermes_cli.update_cmd::_complete_source_update", "hermes_cli.update_cmd::_count_commits_between", "hermes_cli.update_cmd::_critical_module_import_failures", "hermes_cli.update_cmd::_current_checkout_sha", @@ -1097,6 +1386,7 @@ "hermes_cli.version_info::get_code_identity", "hermes_cli::__version__", "hermes_cli::_early_recovery", + "hermes_cli::_launchers", "hermes_cli::_subprocess_compat", "hermes_cli::gateway", "hermes_cli::gateway_windows", diff --git a/tests/hermes_cli/conftest.py b/tests/hermes_cli/conftest.py index 3d2f907462..04cb312604 100644 --- a/tests/hermes_cli/conftest.py +++ b/tests/hermes_cli/conftest.py @@ -61,7 +61,23 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch): @pytest.fixture -def isolated_update_processes(): +def isolated_source_completion(monkeypatch): + """Unit-test the tail in-process; real transport is tested separately.""" + from hermes_cli import update_cmd, update_completion + + monkeypatch.setattr("hermes_cli.source_build.build_update_products", lambda *a, **kw: None) + monkeypatch.setattr("hermes_cli.venv_sync.publish_launchers", lambda *a: None) + + def complete(request): + update_completion._complete_selected(request) + return {"exit_code": 0, "receipt": update_completion._read_terminal_receipt(request), + "windows_resume": request["windows_resume"]} + + monkeypatch.setattr(update_cmd, "run_completion", complete) + + +@pytest.fixture +def isolated_update_processes(isolated_source_completion): """Keep cmd_update's gateway auto-restart phase off this machine's gateways. The restart phase used to swallow every exception at debug level, so these diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 5bdc679fdc..62612d988f 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -18,10 +18,6 @@ def _isolate_venv_holders(monkeypatch): monkeypatch.setattr("hermes_cli.update_cmd_windows._detect_venv_python_processes", lambda: []) -@pytest.fixture(autouse=True) -def _isolate_product_preparation(monkeypatch): - """These tests exercise update orchestration, not PM installs or npm builds.""" - monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None) def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"): @@ -182,7 +178,7 @@ class TestCmdUpdateBranchFallback: assert exit_info.value.code == 1 runtime_check.assert_called_once_with() write_gateway_exit.assert_called_once_with(False) - finalize_receipt.assert_called_once_with("partial") + assert finalize_receipt.call_args.args[0] == "partial" @patch("shutil.which", return_value=None) @patch("subprocess.run") @@ -216,7 +212,7 @@ class TestCmdUpdateBranchFallback: hm, "_sync_with_upstream_if_needed" ), patch.object( update_cmd, - "_run_post_update_maintenance", + "_complete_source_update", # Unlike product preparation, this phase only runs after a pull. # Stop before skills sync and fleet restart; the regression took # the current-checkout path instead and never reached this phase. @@ -242,9 +238,9 @@ class TestCmdUpdateBranchFallback: ), patch( "hermes_cli.update_cmd._reload_config_modules" ), patch( - "hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2) + "hermes_cli.config.check_config_version", return_value=(1, 2) ), patch( - "hermes_cli.update_cmd._run_migrate_config_fresh", + "hermes_cli.config.migrate_config", return_value={"env_added": [], "config_added": ["new.option"]}, ) as migrate_config, patch("hermes_cli.main.sys") as mock_sys: mock_sys.stdin.isatty.return_value = False @@ -285,9 +281,9 @@ class TestCmdUpdateMigrationPrompt: ), patch( "hermes_cli.update_cmd._reload_config_modules" ), patch( - "hermes_cli.update_cmd._run_config_check_fresh", return_value=(5, 24) + "hermes_cli.config.check_config_version", return_value=(5, 24) ), patch( - "hermes_cli.update_cmd._run_migrate_config_fresh", + "hermes_cli.config.migrate_config", return_value={"env_added": [], "config_added": [], "warnings": []}, ) as mock_migrate: mock_run.side_effect = _make_run_side_effect( @@ -324,9 +320,9 @@ class TestCmdUpdateMigrationPrompt: ), patch( "hermes_cli.update_cmd._reload_config_modules" ), patch( - "hermes_cli.update_cmd._run_config_check_fresh", return_value=(33, 34) + "hermes_cli.config.check_config_version", return_value=(33, 34) ), patch( - "hermes_cli.update_cmd._run_migrate_config_fresh", + "hermes_cli.config.migrate_config", return_value={ "env_added": [], "config_added": ["display.personality=none (one-time reset)"], @@ -366,9 +362,9 @@ class TestCmdUpdateMigrationPrompt: ), patch( "hermes_cli.update_cmd._reload_config_modules" ), patch( - "hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 24) + "hermes_cli.config.check_config_version", return_value=(1, 24) ), patch( - "hermes_cli.update_cmd._run_migrate_config_fresh", + "hermes_cli.config.migrate_config", return_value={"env_added": [], "config_added": [], "warnings": []}, ), patch("hermes_cli.main.sys") as mock_sys: mock_sys.stdin.isatty.return_value = True @@ -386,35 +382,6 @@ class TestCmdUpdateMigrationPrompt: assert "display.new_widget" in out -class TestConfigVersionCheckUsesFreshModules: - """Regression: config migration must use freshly-reloaded modules, not the - sys.modules cache from before git pull. - - Before the fix, ``hermes update`` ran in the PRE-pull Python process. - After ``git pull`` updated the source on disk, function-level imports - returned the OLD cached ``hermes_cli.config`` module — so - ``DEFAULT_CONFIG["_config_version"]`` was stale and - ``check_config_version()`` reported ``(33, 33)`` "up to date" even though - the freshly-pulled code had v34 with a migration to run. The personality - reset migration (#81946) was silently skipped this way. - """ - - def test_run_config_check_fresh_reloads_modules(self): - """_run_config_check_fresh must call _reload_config_modules which - force-reloads the config modules from disk. - - Regression: config migration was silently skipped because - sys.modules held the OLD hermes_cli.config with the OLD - DEFAULT_CONFIG["_config_version"] after git pull. - """ - from unittest.mock import patch - - import hermes_cli.update_cmd as update_cmd - - with patch.object(update_cmd, "_reload_config_modules") as mock_reload: - update_cmd._run_config_check_fresh() - - mock_reload.assert_called_once() class TestCmdUpdateProfileSkillSync: @@ -720,7 +687,7 @@ class TestCmdUpdateZipBranchRefusal: args = SimpleNamespace(branch="bb/gui") with pytest.raises(SystemExit) as exc_info: - _update_via_zip(args) + _update_via_zip(args, completion_request={}) assert exc_info.value.code == 1 out = capsys.readouterr().out @@ -738,6 +705,7 @@ class TestZipDesktopPreservation: pre-update desktop selection (#70337/#87331). """ import zipfile + from pathlib import Path from hermes_cli import main as hm from hermes_cli import update_cmd @@ -761,8 +729,8 @@ class TestZipDesktopPreservation: preparations = [] - def prepare_checkout(root, *, desktop): - preparations.append((root, desktop, packaged_exe.read_bytes())) + def prepare_checkout(request): + preparations.append((Path(request["source"]), request["desktop"], packaged_exe.read_bytes())) monkeypatch.setattr(hm, "PROJECT_ROOT", project_root) monkeypatch.setattr(hm, "_is_windows", lambda: True) @@ -775,7 +743,7 @@ class TestZipDesktopPreservation: lambda _desktop_dir: packaged_exe if packaged_exe.exists() else None, ) monkeypatch.setattr(hm, "_desktop_dist_exists", lambda _desktop_dir: False) - monkeypatch.setattr(update_cmd_maint, "_prepare_updated_checkout", prepare_checkout) + monkeypatch.setattr(update_cmd, "_complete_source_update", prepare_checkout) monkeypatch.setattr(hm, "_clear_bytecode_cache", lambda *_args: 0) monkeypatch.setattr(hm, "_record_bytecode_fingerprint", lambda: None) monkeypatch.setattr(hm, "_refresh_bootstrap_cache_scripts", lambda _branch: None) diff --git a/tests/hermes_cli/test_pending_supervisor_recovery.py b/tests/hermes_cli/test_pending_supervisor_recovery.py index 2b62ff8ae7..a0a29618d7 100644 --- a/tests/hermes_cli/test_pending_supervisor_recovery.py +++ b/tests/hermes_cli/test_pending_supervisor_recovery.py @@ -7,52 +7,7 @@ import pytest from hermes_cli import gateway, main, update_cmd_fleet as fleet -@pytest.mark.platforms("linux") -@pytest.mark.parametrize("failure", ["listing", "timeout", "missing", "restart", "inactive", "running", None]) -def test_pending_marker_requires_complete_systemd_recovery(monkeypatch, tmp_path, failure): - monkeypatch.setattr(main, "_purge_stale_hermes_modules", lambda: None) - stopped = [] - monkeypatch.setattr(gateway, "find_gateway_pids", lambda **kw: [123] if failure == "running" and not stopped else []) - monkeypatch.setattr(gateway, "kill_gateway_processes", lambda **kw: stopped.append(True)) - monkeypatch.setattr(gateway, "_wait_for_gateway_exit", lambda **kw: None) - monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True) - monkeypatch.setattr(fleet, "_SYSTEMD_SCOPES", (("user", ["systemctl", "--user"]),)) - monkeypatch.setattr(fleet._time, "sleep", lambda _: None) - ticks = iter(range(1000)) - monkeypatch.setattr(fleet._time, "monotonic", lambda: next(ticks)) - recovered = [] - def systemctl(cmd, **kw): - if "list-units" in cmd: - if stopped: - return SimpleNamespace(returncode=0, stdout="", stderr="") - if failure == "timeout": - raise subprocess.TimeoutExpired(cmd, 10) - if failure == "missing": - raise FileNotFoundError("systemctl") - return SimpleNamespace(returncode=int(failure == "listing"), stdout=( - "hermes-gateway-one.service loaded active running\n" - "hermes-gateway-two.service loaded failed failed\n"), stderr="") - bad = cmd[-1] == "hermes-gateway-two" - if "restart" in cmd: - recovered.append(cmd[-1]) - return SimpleNamespace(returncode=int(bad and failure == "restart"), stdout="") - if "is-active" in cmd: - active = not (bad and failure == "inactive") - return SimpleNamespace(returncode=0 if active else 3, stdout="active" if active else "inactive") - return SimpleNamespace(returncode=0, stdout="0s") - - monkeypatch.setattr(fleet, "_systemctl", systemctl) - marker = fleet._fleet_restart_pending_marker_path() - marker.write_text("expected_sha=pending\n") - if failure not in (None, "running"): - with pytest.raises(SystemExit, match="1"): - fleet._apply_pending_fleet_restart_catchup() - assert marker.exists() - else: - fleet._apply_pending_fleet_restart_catchup() - assert not marker.exists() - assert set(recovered) == {"hermes-gateway-one", "hermes-gateway-two"} @pytest.mark.parametrize("failure", ["listing", "restart", "inactive", "unloaded", None]) diff --git a/tests/hermes_cli/test_source_release_channels.py b/tests/hermes_cli/test_source_release_channels.py index 3009fa4b2f..430beaa7ae 100644 --- a/tests/hermes_cli/test_source_release_channels.py +++ b/tests/hermes_cli/test_source_release_channels.py @@ -125,20 +125,22 @@ def test_source_check_and_apply_land_on_selected_release(releases, monkeypatch, # Exercise the real selection/fetch/checkout path, not dependency installation # or live service management. No host OS is simulated. opts = update_cmd._UpdateOptions( - active_lazy_features=[], pre_update_version=None, gw_input_fn=None, + pre_update_version=None, gw_input_fn=None, assume_yes=True, keep_stash=False, switch_branch=False, discard_local_changes=False, ) monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *_: opts) monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda *_: None) monkeypatch.setattr(main, "_run_pre_update_backup", lambda *_: None) - monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: []) + monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None) monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (False, ["git"], False)) applied = [] - monkeypatch.setattr(update_cmd, "_apply_pulled_update", lambda *a, **k: applied.append(git(releases.root, "rev-parse", "HEAD"))) + monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: applied.append(request)) args = SimpleNamespace(branch=None, channel=None, force_venv=True) update_cmd._cmd_update_impl(args, False) expected = releases.commits[1 if channel == "stable" else 2] - assert applied == [expected] + assert len(applied) == 1 + assert applied[0]["expected_sha"] == expected + assert applied[0]["source"] == str(releases.root.resolve()) assert git(releases.root, "rev-parse", "HEAD") == expected if start != "old": assert git(releases.root, "rev-parse", "my-work") == branch_sha @@ -185,7 +187,7 @@ def test_zip_fallback_keeps_selected_repository_and_commit(releases, monkeypatch with pytest.raises(DownloadBoundary): update_cmd_zip._update_via_zip( SimpleNamespace(branch=None), target_sha=releases.commits[2], - target_repository="Fixture/hermes-agent") + target_repository="Fixture/hermes-agent", completion_request={}) assert seen == [f"https://github.com/Fixture/hermes-agent/archive/{releases.commits[2]}.zip"] diff --git a/tests/hermes_cli/test_update_completion_process.py b/tests/hermes_cli/test_update_completion_process.py new file mode 100644 index 0000000000..c48cf66f16 --- /dev/null +++ b/tests/hermes_cli/test_update_completion_process.py @@ -0,0 +1,391 @@ +"""A checkout transition must not finish in the old interpreter's module graph.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import venv + +import pytest + + +@pytest.fixture +def transition(tmp_path): + root = tmp_path / "checkout" + root.mkdir() + home = tmp_path / "home" + home.mkdir() + package = root / "hermes_cli" + package.mkdir() + (package / "__init__.py").write_text("") + (root / "pm").mkdir() + (root / "pm/__init__.py").write_text("OLD_API = True\n") + + def git(*args): + return subprocess.run(["git", *args], cwd=root, text=True, capture_output=True, check=True).stdout.strip() + + git("init", "-b", "main") + git("config", "user.name", "Completion test") + git("config", "user.email", "completion@example.invalid") + git("add", ".") + git("-c", "commit.gpgsign=false", "commit", "-m", "old incompatible runtime") + old = git("rev-parse", "HEAD") + # Deliberately incompatible: a cached OLD_API-only PM cannot prepare this tree. + (root / "pm/__init__.py").write_text( + "from hermes_cli.probe import event\n" + "def sync_venv(*, explicit, project_root):\n" + " assert explicit\n" + " event('prepare')\n" + ) + (root / "pm/receipt.py").write_text( + "from contextlib import nullcontext\n" + "worker_context = lambda update_id: nullcontext()\n" + "last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'success'}\n" + "def accept_worker_receipt(data, update_id):\n" + " assert data['update_id'] == update_id\n" + ) + (package / "probe.py").write_text( + "import json, os, pathlib, sys\n" + "def event(name, **values):\n" + " with pathlib.Path('events.jsonl').open('a') as f:\n" + " f.write(json.dumps(dict(name=name, pid=os.getpid(), python=sys.executable, **values)) + '\\n')\n" + ) + (package / "runtime_paths.py").write_text( + "import os, sys\n" + "from pathlib import Path\n" + "selected_venv = lambda root: Path(sys.executable).parent.parent\n" + "activation_environment = lambda root: {**os.environ, 'PYTHONPATH': str(root)}\n" + "def activate_dependencies(root):\n" + " from hermes_cli.probe import event\n" + " event('activate')\n" + ) + selected = tmp_path / "selected-python" + venv.EnvBuilder(with_pip=False).create(selected) + selected_python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + (root / "hermes_constants.py").write_text( + f"venv_python_path = lambda root: {str(selected_python)!r}\n" + ) + (package / "venv_sync.py").write_text( + "from hermes_cli.probe import event\n" + "publish_launchers = lambda root: event('launchers')\n" + ) + (package / "source_build.py").write_text( + "from hermes_cli.probe import event\n" + "def build_update_products(root, *, desktop): event('build', desktop=desktop)\n" + ) + (package / "main.py").write_text("") + (package / "update_cmd_config.py").write_text("_LAST_SIBLING_SNAPSHOTS = {}\n") + (package / "update_inventory.py").write_text( + "from types import SimpleNamespace\nRuntimeRecord = UpdatePlan = SimpleNamespace\n" + ) + (package / "update_cmd_maint.py").write_text( + "from hermes_cli.probe import event\n" + "def _run_post_update_maintenance(**kwargs):\n" + " from hermes_cli.update_cmd_config import _LAST_SIBLING_SNAPSHOTS\n" + " event('maintenance', snapshots=_LAST_SIBLING_SNAPSHOTS, **kwargs)\n" + " return True\n" + ) + (package / "update_cmd.py").write_text( + "from hermes_cli.probe import event\n" + "_invalidate_update_cache = lambda: event('cache')\n" + "_sweep_bytecode_after_update = lambda branch: event('bytecode')\n" + "_write_fleet_restart_pending_marker = lambda **kw: event('pending')\n" + "_write_gateway_update_exit_code = lambda ok: event('exit_marker', ok=ok)\n" + "def _restart_gateway_fleet_after_update(plan, gateway_mode):\n" + " event('restart', profiles=[r.profile for r in plan.runtimes])\n" + " return object()\n" + "def _resume_windows_gateways_and_merge_outcome(out, token, gateway_mode):\n" + " token['resume_needed'] = False\n" + " event('resume')\n" + "def _resume_windows_gateways_after_update(token):\n" + " if token and token.get('resume_needed'):\n" + " token['resume_needed'] = False\n" + " event('emergency_resume')\n" + "def _verify_fleet_after_update(out, **kw):\n" + " from hermes_cli.update_receipt import finalize_pending_update_receipt\n" + " event('verify')\n" + " finalize_pending_update_receipt(0, 'verified')\n" + ) + (package / "update_receipt.py").write_text( + "import contextvars, json, os, pathlib\n" + "_current = contextvars.ContextVar('receipt', default=None)\n" + "class UpdateReceipt: pass\n" + "def finalize_pending_update_receipt(code, reason):\n" + " r = _current.get()\n" + " if r is None: return\n" + " r.data.update(exit_code=code, outcome='success' if code == 0 else 'failed', finished_at='now')\n" + " path = pathlib.Path(os.environ['HERMES_HOME']) / 'logs/update_receipts'\n" + " path.mkdir(parents=True, exist_ok=True)\n" + " path = path / ('update_test_' + r.correlation_id + '.json')\n" + " path.write_text(json.dumps(r.data))\n" + " _current.set(None)\n" + " return path\n" + ) + git("add", ".") + git("-c", "commit.gpgsign=false", "commit", "-m", "new incompatible runtime") + new = git("rev-parse", "HEAD") + request = { + "schema": 1, "source": str(root), "home": str(home), "branch": "main", + "desktop": True, "assume_yes": True, "gateway_mode": True, + "pre_update_version": "old", "snapshot_id": "active-before", + "sibling_snapshots": {"work": "work-before"}, + "plan": {"runtimes": [{"kind": "gateway", "profile": "work"}]}, + "receipt": {"update_id": "b" * 32, "outcome": "running", "steps": []}, + "windows_resume": {"resume_needed": True, "profiles": {"work": [123]}}, + } + return root, git, old, new, request + + +def test_old_process_new_git_tree_completes_in_fresh_python(transition, tmp_path): + from hermes_cli import update_completion + + root, git, old, new, request = transition + # Copy executable code, not its text shape: the process exercises the real transport. + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + git("add", ".") + git("-c", "commit.gpgsign=false", "commit", "-m", "completion entrypoint") + new = git("rev-parse", "HEAD") + git("checkout", old) + driver = ( + "import importlib.util, json, os, subprocess, sys\n" + "spec = importlib.util.spec_from_file_location('transport', sys.argv[1])\n" + "transport = importlib.util.module_from_spec(spec); spec.loader.exec_module(transport)\n" + "import pm\nassert pm.OLD_API\n" + "subprocess.run(['git', 'checkout', sys.argv[2]], check=True)\n" + "result = transport.run_completion(json.loads(sys.argv[3]))\n" + "assert pm.OLD_API, 'transport mutated old module graph'\n" + "print('RESULT=' + json.dumps(result))\n" + ) + result = subprocess.run( + [sys.executable, "-c", driver, update_completion.__file__, new, json.dumps(request)], + cwd=root, env={**os.environ, "PYTHONPATH": str(root), "HERMES_HOME": request["home"]}, + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + response = json.loads(result.stdout.split("RESULT=")[1]) + assert response["exit_code"] == 0 + assert response["receipt"]["update_id"] == request["receipt"]["update_id"] + assert response["windows_resume"]["resume_needed"] is False + events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()] + by_name = {event["name"]: event for event in events} + assert by_name["activate"]["pid"] == by_name["build"]["pid"] + assert by_name["prepare"]["pid"] != by_name["build"]["pid"] + assert Path(by_name["build"]["python"]).is_relative_to(root.parent / "selected-python") + assert by_name["build"]["pid"] == by_name["maintenance"]["pid"] == by_name["restart"]["pid"] + assert by_name["maintenance"]["snapshots"] == {"work": "work-before"} + assert by_name["maintenance"]["pre_update_snapshot_id"] == "active-before" + assert by_name["restart"]["profiles"] == ["work"] + assert [e["name"] for e in events].index("exit_marker") < [e["name"] for e in events].index("restart") + + +@pytest.mark.parametrize("code", [0, 23]) +def test_missing_child_result_fails_boundary_receipt_and_releases_lock(transition, monkeypatch, code): + from types import SimpleNamespace + from hermes_cli import main, update_cmd, update_receipt, update_lock + + root, git, old, new, request = transition + (root / "hermes_cli/update_completion.py").write_text(f"import os\nos._exit({code})\n") + monkeypatch.setenv("HERMES_HOME", request["home"]) + monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False) + monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None) + monkeypatch.setattr(main, "_finalize_update_output", lambda state: None) + + def complete(args, gateway_mode): + update_receipt.begin_update_receipt() + request["receipt"] = update_receipt._current.get().data + update_cmd._complete_source_update(request) + + monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete) + with pytest.raises(SystemExit) as error: + main.cmd_update(SimpleNamespace(gateway=True)) + assert error.value.code == (code or 1) + receipt = update_receipt.read_latest_receipt() + assert receipt["outcome"] == "failed" + assert receipt["exit_code"] == (code or 1) + assert receipt["update_id"] == request["receipt"]["update_id"] + assert request["windows_resume"]["resume_needed"] is True + assert (Path(request["home"]) / ".update_exit_code").read_text().strip() == "1" + lock = update_lock.UpdateLock() + assert lock.acquire() + lock.release() + + +@pytest.mark.platforms("posix") +def test_killed_selected_python_returns_signal_exit_status(transition): + from hermes_cli import update_completion + + root, git, old, new, request = transition + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + (root / "hermes_cli/source_build.py").write_text( + "import os, signal\n" + "def build_update_products(*a, **kw): os.kill(os.getpid(), signal.SIGKILL)\n" + ) + result = update_completion.run_completion(request) + assert result["exit_code"] == 137 + assert result["pm_receipt"]["update_id"] == request["receipt"]["update_id"] + + +def test_failed_build_preserves_exit_status_without_maintenance(transition): + from hermes_cli import update_completion + + root, git, old, new, request = transition + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + (root / "hermes_cli/source_build.py").write_text( + "import subprocess\n" + "def build_update_products(*a, **kw): raise subprocess.CalledProcessError(23, ['builder'])\n" + ) + result = update_completion.run_completion(request) + assert result["exit_code"] == 23 + assert result["receipt"]["outcome"] == "failed" + events = [json.loads(line)["name"] for line in (root / "events.jsonl").read_text().splitlines()] + assert "maintenance" not in events + assert "restart" not in events + assert "emergency_resume" in events + + +def test_prepare_failure_preserves_correlated_pm_receipt(transition, monkeypatch): + from types import SimpleNamespace + from hermes_cli import main, update_cmd, update_completion, update_receipt + + root, git, old, new, request = transition + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + (root / "pm/__init__.py").write_text( + "def sync_venv(**kw): raise RuntimeError('dependency refused')\n" + ) + with (root / "pm/receipt.py").open("a") as stream: + stream.write("last_for_update = lambda update_id: {'update_id': update_id, 'outcome': 'refused', 'refusal': {'reason': 'dependency refused'}}\n") + monkeypatch.setenv("HERMES_HOME", request["home"]) + monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False) + monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None) + monkeypatch.setattr(main, "_finalize_update_output", lambda state: None) + + def complete(args, gateway_mode): + update_receipt.begin_update_receipt() + request["receipt"] = update_receipt._current.get().data + update_cmd._complete_source_update(request) + + monkeypatch.setattr(update_cmd, "_cmd_update_impl", complete) + with pytest.raises(SystemExit) as error: + main.cmd_update(SimpleNamespace(gateway=True)) + assert error.value.code == 1 + receipt = update_receipt.read_latest_receipt() + assert receipt["update_id"] == request["receipt"]["update_id"] + assert receipt["pm_sync_outcome"] == "refused" + assert receipt["pm_refusal"] == {"reason": "dependency refused"} + + +def test_bootstrap_does_not_initialize_old_site_packages(transition, tmp_path, monkeypatch): + from hermes_cli import update_completion + + root, git, old, new, request = transition + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + obsolete = tmp_path / "obsolete-python" + venv.EnvBuilder(with_pip=False).create(obsolete) + site = obsolete / ("Lib/site-packages" if os.name == "nt" else + f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages") + trap = tmp_path / "old-site-loaded" + (site / "application.pth").write_text(f"import pathlib; pathlib.Path({str(trap)!r}).touch()\n") + monkeypatch.setattr(sys, "executable", str(obsolete / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))) + result = update_completion.run_completion(request) + assert result["exit_code"] == 0 + assert not trap.exists(), "preparation initialized the old application's .pth graph" + + +@pytest.mark.platforms("posix") +def test_interactive_configuration_keeps_terminal_input(transition): + import pty + import select + import signal + import time + from hermes_cli import update_completion + + root, git, old, new, request = transition + shutil.copy2(update_completion.__file__, root / "hermes_cli/update_completion.py") + (root / "hermes_cli/update_cmd_maint.py").write_text( + "import sys\nfrom hermes_cli.probe import event\n" + "def _run_post_update_maintenance(**kw):\n" + " assert sys.stdin.isatty() and sys.stdout.isatty()\n" + " event('answer', value=input('CONFIG? '))\n" + " return True\n" + ) + master, slave = pty.openpty() + driver = "import json,runpy,sys; m=runpy.run_path(sys.argv[1]); raise SystemExit(m['run_completion'](json.loads(sys.argv[2]))['exit_code'])" + proc = subprocess.Popen([sys.executable, "-c", driver, update_completion.__file__, json.dumps(request)], + cwd=root, stdin=slave, stdout=slave, stderr=slave) + os.close(slave) + output = b"" + try: + deadline = time.monotonic() + 20 + while b"CONFIG?" not in output: + assert time.monotonic() < deadline, output.decode(errors="replace") + if select.select([master], [], [], 0.1)[0]: + output += os.read(master, 8192) + os.write(master, b"yes\n") + assert proc.wait(timeout=20) == 0 + events = [json.loads(line) for line in (root / "events.jsonl").read_text().splitlines()] + assert next(e for e in events if e["name"] == "answer")["value"] == "yes" + finally: + if proc.poll() is None: + proc.send_signal(signal.SIGINT) + proc.wait(timeout=5) + os.close(master) + + +@pytest.mark.platforms("posix") +@pytest.mark.live_system_guard_bypass +def test_interrupt_reaps_completion_descendants_before_return(transition, monkeypatch): + import io + import psutil + import time + from hermes_cli import update_completion + + root, git, old, new, request = transition + (root / "hermes_cli/update_completion.py").write_text( + "import subprocess, sys, time\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'])\n" + "print('READY ' + str(child.pid), flush=True)\n" + "time.sleep(600)\n" + ) + pids = [] + + class Interrupt(io.StringIO): + def write(self, value): + if 'READY ' in value: + pids.append(int(value.split('READY ')[1].strip())) + raise KeyboardInterrupt() + return super().write(value) + + monkeypatch.setattr(sys, "stdout", Interrupt()) + try: + with pytest.raises(KeyboardInterrupt): + update_completion.run_completion(request) + assert pids + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if not psutil.pid_exists(pids[0]) or psutil.Process(pids[0]).status() == psutil.STATUS_ZOMBIE: + break + time.sleep(0.02) + else: + pytest.fail("completion descendant survived parent cancellation") + finally: + for pid in pids: + if psutil.pid_exists(pid): + psutil.Process(pid).kill() + + +def test_forged_terminal_receipt_cannot_acknowledge_success(transition): + from hermes_cli.update_completion import run_completion + + root, git, old, new, request = transition + (root / "hermes_cli/update_completion.py").write_text( + "import json, pathlib, sys\n" + "request = json.loads(pathlib.Path(sys.argv[1]).read_text())\n" + "pathlib.Path(sys.argv[2]).write_text(json.dumps(dict(\n" + " schema=1, update_id=request['receipt']['update_id'], exit_code=0,\n" + " windows_resume={}, receipt={'update_id': 'wrong', 'outcome': 'success'})))\n" + ) + response = run_completion(request) + assert response["exit_code"] != 0 + assert response["receipt"] is None diff --git a/tests/hermes_cli/test_update_completion_routing.py b/tests/hermes_cli/test_update_completion_routing.py new file mode 100644 index 0000000000..e9a81d548e --- /dev/null +++ b/tests/hermes_cli/test_update_completion_routing.py @@ -0,0 +1,50 @@ +"""All source selection routes hand off once, without old-process maintenance.""" +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from hermes_cli import update_cmd, update_cmd_zip + + +@pytest.mark.parametrize("hook,args,kwargs", [ + (update_cmd._prepare_updated_checkout, ("unused",), {"desktop": False}), + (update_cmd._reload_config_modules, (), {}), + (update_cmd._reload_process_scan_modules, (), {}), + (update_cmd._run_pending_fleet_restart, (), {}), +]) +def test_historical_completion_hook_never_reports_success(hook, args, kwargs, capsys): + with pytest.raises(SystemExit) as error: + hook(*args, **kwargs) + assert error.value.code != 0 + assert "update" in capsys.readouterr().err.lower() + + +@pytest.mark.parametrize("route", ["pulled", "current", "zip"]) +def test_every_route_hands_off_once(route, tmp_path, monkeypatch): + request = {"branch": "main", "receipt": {"update_id": "c" * 32}} + handed_off = [] + monkeypatch.setattr(update_cmd, "_complete_source_update", handed_off.append, raising=False) + monkeypatch.setattr(update_cmd, "_m", lambda: SimpleNamespace( + PROJECT_ROOT=tmp_path, _resolve_update_branch=lambda args: "main")) + monkeypatch.setattr(update_cmd, "_verify_head_after_pull", lambda *a, **kw: "new-sha") + monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **kw: pytest.fail("old-process preparation")) + monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None) + monkeypatch.setattr(update_cmd, "_sweep_bytecode_after_update", lambda *a: None) + monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None) + swap = Mock() + monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", swap) + plan = SimpleNamespace(in_place_update=False, auto_stash_ref=None, parked_branch_switched=False, + upstream_checked=True) + opts = SimpleNamespace(assume_yes=True, gw_input_fn=None, pre_update_version="old") + if route == "pulled": + update_cmd._apply_pulled_update( + ["git"], "main", "old-sha", plan, opts, is_fork=False, _windows_gateway_resume=None, + completion_request=request) + elif route == "current": + update_cmd._finish_already_up_to_date( + ["git"], "main", "main", plan, gw_input_fn=None, completion_request=request) + else: + assert update_cmd_zip._update_via_zip(SimpleNamespace(), completion_request=request) is True + swap.assert_called_once() + assert handed_off == [request] diff --git a/tests/hermes_cli/test_update_config_migration_on_current.py b/tests/hermes_cli/test_update_config_migration_on_current.py index 907818a3dd..12175deba6 100644 --- a/tests/hermes_cli/test_update_config_migration_on_current.py +++ b/tests/hermes_cli/test_update_config_migration_on_current.py @@ -18,6 +18,7 @@ from __future__ import annotations import contextlib import io +from hermes_cli import config as update_config from unittest.mock import patch import hermes_cli.update_cmd as update_cmd @@ -39,9 +40,9 @@ def _run(current: int, latest: int): ), patch( "hermes_cli.config.get_missing_config_fields", return_value=[] ), patch.object( - update_cmd, "_run_config_check_fresh", return_value=(current, latest) + update_config, "check_config_version", return_value=(current, latest) ), patch.object( - update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate + update_config, "migrate_config", side_effect=_fake_migrate ), patch.object( update_cmd, "_migrate_sibling_profile_configs", return_value=[] ): @@ -88,9 +89,9 @@ def test_surfaces_migration_warnings(): ), patch( "hermes_cli.config.get_missing_config_fields", return_value=[] ), patch.object( - update_cmd, "_run_config_check_fresh", return_value=(37, 38) + update_config, "check_config_version", return_value=(37, 38) ), patch.object( - update_cmd, "_run_migrate_config_fresh", side_effect=_fake_migrate + update_config, "migrate_config", side_effect=_fake_migrate ), patch.object( update_cmd, "_migrate_sibling_profile_configs", return_value=[] ): @@ -109,9 +110,9 @@ def test_check_failure_does_not_break_repair_path(): ), patch( "hermes_cli.config.get_missing_config_fields", return_value=[] ), patch.object( - update_cmd, "_run_config_check_fresh", side_effect=RuntimeError("boom") + update_config, "check_config_version", side_effect=RuntimeError("boom") ), patch.object( - update_cmd, "_run_migrate_config_fresh", return_value={} + update_config, "migrate_config", return_value={} ) as mig: buf = io.StringIO() with contextlib.redirect_stdout(buf): diff --git a/tests/hermes_cli/test_update_config_migration_on_current_checkout.py b/tests/hermes_cli/test_update_config_migration_on_current_checkout.py index 91783fe5f7..ccdb29fdae 100644 --- a/tests/hermes_cli/test_update_config_migration_on_current_checkout.py +++ b/tests/hermes_cli/test_update_config_migration_on_current_checkout.py @@ -8,83 +8,28 @@ install is not left in a non-bootable state with new code on old config version. from __future__ import annotations +from hermes_cli import config as update_config from unittest.mock import MagicMock, patch from hermes_cli import update_cmd -def test_current_checkout_runs_config_migration_on_version_bump(capsys): - """A retry migrates old config after preparing the updated checkout.""" - completion = MagicMock(return_value=True) - with ( - patch.object(update_cmd, "_prepare_updated_checkout") as prepare, - patch.object(update_cmd, "_m") as m, - patch.object(update_cmd, "_reload_config_modules"), - patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)), - patch("hermes_cli.config.get_missing_env_vars", return_value=[]), - patch("hermes_cli.config.get_missing_config_fields", return_value=[]), - patch.object( - update_cmd, - "_run_migrate_config_fresh", - return_value={"env_added": [], "config_added": ["migrated to v38"], "warnings": []}, - ) as mock_migrate, - patch.object(update_cmd, "_print_verified_update_completion", completion), - ): - complete = update_cmd._repair_current_checkout( - assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None, - had_desktop_app_before_update=False, upstream_checked=True, - ) - - assert complete is True - prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False) - mock_migrate.assert_called_once_with(interactive=False, quiet=True) - completion.assert_called_once_with("✓ Already up to date!") - out = capsys.readouterr().out - assert "Checking configuration for new options..." in out - assert "Updating config format (v37 → v38)…" in out - assert "Config format updated" in out -def test_current_checkout_up_to_date_config(capsys): - """When config is already up to date, it reports up to date without error.""" - completion = MagicMock(return_value=True) - with ( - patch.object(update_cmd, "_prepare_updated_checkout") as prepare, - patch.object(update_cmd, "_m") as m, - patch.object(update_cmd, "_reload_config_modules"), - patch.object(update_cmd, "_run_config_check_fresh", return_value=(38, 38)), - patch("hermes_cli.config.get_missing_env_vars", return_value=[]), - patch("hermes_cli.config.get_missing_config_fields", return_value=[]), - patch.object(update_cmd, "_run_migrate_config_fresh") as mock_migrate, - patch.object(update_cmd, "_print_verified_update_completion", completion), - ): - complete = update_cmd._repair_current_checkout( - assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None, - had_desktop_app_before_update=False, upstream_checked=True, - ) - - assert complete is True - prepare.assert_called_once_with(m.return_value.PROJECT_ROOT, desktop=False) - mock_migrate.assert_not_called() - completion.assert_called_once_with("✓ Already up to date!") - out = capsys.readouterr().out - assert "Checking configuration for new options..." in out - assert "Configuration is up to date" in out def test_check_and_apply_config_migration_interactive_prompt(): """When new config options exist in an interactive session, it prompts the user.""" with ( patch.object(update_cmd, "_reload_config_modules"), - patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)), + patch.object(update_config, "check_config_version", return_value=(37, 38)), patch("hermes_cli.config.get_missing_env_vars", return_value=[{"name": "NEW_KEY", "description": "desc"}]), patch("hermes_cli.config.get_missing_config_fields", return_value=[]), patch("sys.stdin.isatty", return_value=True), patch("sys.stdout.isatty", return_value=True), patch("builtins.input", return_value="y"), patch.object( - update_cmd, - "_run_migrate_config_fresh", + update_config, "migrate_config", return_value={"env_added": ["NEW_KEY"], "config_added": [], "warnings": []}, ) as mock_migrate, ): @@ -97,12 +42,11 @@ def test_check_and_apply_config_migration_assume_yes(): """When assume_yes=True, it applies migrations non-interactively without prompting.""" with ( patch.object(update_cmd, "_reload_config_modules"), - patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)), + patch.object(update_config, "check_config_version", return_value=(37, 38)), patch("hermes_cli.config.get_missing_env_vars", return_value=[{"name": "NEW_KEY"}]), patch("hermes_cli.config.get_missing_config_fields", return_value=[]), patch.object( - update_cmd, - "_run_migrate_config_fresh", + update_config, "migrate_config", return_value={"env_added": [], "config_added": ["opt"], "warnings": []}, ) as mock_migrate, ): @@ -115,14 +59,13 @@ def test_check_and_apply_config_migration_non_interactive(): """In a non-interactive session (e.g. CI/scripts), it applies safe migrations automatically.""" with ( patch.object(update_cmd, "_reload_config_modules"), - patch.object(update_cmd, "_run_config_check_fresh", return_value=(37, 38)), + patch.object(update_config, "check_config_version", return_value=(37, 38)), patch("hermes_cli.config.get_missing_env_vars", return_value=[]), patch("hermes_cli.config.get_missing_config_fields", return_value=[{"key": "new_setting"}]), patch("sys.stdin.isatty", return_value=False), patch("sys.stdout.isatty", return_value=False), patch.object( - update_cmd, - "_run_migrate_config_fresh", + update_config, "migrate_config", return_value={"env_added": [], "config_added": ["new_setting"], "warnings": []}, ) as mock_migrate, ): diff --git a/tests/hermes_cli/test_update_desktop_stale_warning.py b/tests/hermes_cli/test_update_desktop_stale_warning.py index 1a98f77253..7bef12082e 100644 --- a/tests/hermes_cli/test_update_desktop_stale_warning.py +++ b/tests/hermes_cli/test_update_desktop_stale_warning.py @@ -123,7 +123,7 @@ def test_maintenance_returns_sqlite_verdict_without_frontend_flags(monkeypatch, @pytest.mark.parametrize("already_restarted_units", [None, {"hermes-serve"}]) -def test_dashboard_refresh_reloads_then_preserves_restart_bookkeeping( +def test_dashboard_refresh_preserves_restart_bookkeeping( already_restarted_units, monkeypatch, capsys, ): order = [] @@ -138,7 +138,7 @@ def test_dashboard_refresh_reloads_then_preserves_restart_bookkeeping( ) update_cmd_maint._refresh_dashboard_after_update(already_restarted_units=already_restarted_units) - assert order == ["reload", { + assert order == [{ "restart_managed": True, "already_restarted_units": already_restarted_units, }] assert "could not be auto-restarted" in capsys.readouterr().out diff --git a/tests/hermes_cli/test_update_fleet_restart_pending.py b/tests/hermes_cli/test_update_fleet_restart_pending.py index 538a23511c..7359ea4fe6 100644 --- a/tests/hermes_cli/test_update_fleet_restart_pending.py +++ b/tests/hermes_cli/test_update_fleet_restart_pending.py @@ -29,6 +29,8 @@ import hermes_cli.update_cmd_fleet as update_cmd_fleet from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON from hermes_constants import get_hermes_home +pytestmark = pytest.mark.usefixtures("isolated_source_completion") + def _make_head_moved_side_effect(pre_sha="abc123", post_sha="def456"): """Simulate git commands where HEAD advances from pre_sha to post_sha.""" @@ -374,19 +376,6 @@ def test_stale_fleet_matrix_on_latest_receipt_is_pending(monkeypatch): assert update_cmd._pending_fleet_restart_needed() is True -def test_run_pending_restart_true_when_no_gateways(monkeypatch, capsys): - monkeypatch.setattr( - "hermes_cli.gateway.find_gateway_pids", lambda **k: [] - ) - monkeypatch.setattr(hermes_main, "_purge_stale_hermes_modules", lambda: None) - - # An empty PID scan is insufficient; both supervisor scopes must answer empty. - monkeypatch.setattr(update_cmd_fleet, "_systemd_gateway_unit_listings", lambda: [ - (scope, cmd, SimpleNamespace(returncode=0, stdout="")) - for scope, cmd in update_cmd_fleet._SYSTEMD_SCOPES - ]) - assert update_cmd._run_pending_fleet_restart() is True - assert "Pending fleet restart completed" in capsys.readouterr().out # --------------------------------------------------------------------------- @@ -536,19 +525,19 @@ def test_already_up_to_date_runs_pending_restart_when_marker_present( seen = {"ran": False} - def _restart(): + original = update_cmd._restart_gateway_fleet_after_update + def _restart(*args): seen["ran"] = True - return True + return original(*args) - monkeypatch.setattr(update_cmd, "_run_pending_fleet_restart", _restart) - monkeypatch.setattr(update_cmd_fleet, "_run_pending_fleet_restart", _restart) + monkeypatch.setattr(update_cmd, "_restart_gateway_fleet_after_update", _restart) hermes_main.cmd_update(args) assert seen["ran"] is True assert not update_cmd._fleet_restart_pending_marker_path().exists() out = capsys.readouterr().out - assert "did not restart running gateways" in out + assert "Already up to date!" in out def test_already_up_to_date_runs_pending_restart_when_receipt_skewed( @@ -585,46 +574,19 @@ def test_already_up_to_date_runs_pending_restart_when_receipt_skewed( ) seen = {"ran": False} - monkeypatch.setattr( - update_cmd, - "_run_pending_fleet_restart", - lambda: seen.__setitem__("ran", True) or True, - ) - monkeypatch.setattr( - update_cmd_fleet, - "_run_pending_fleet_restart", - lambda: seen.__setitem__("ran", True) or True, - ) + original = update_cmd._restart_gateway_fleet_after_update + def restart(*args): + seen["ran"] = True + return original(*args) + monkeypatch.setattr(update_cmd, "_restart_gateway_fleet_after_update", restart) hermes_main.cmd_update(args) assert seen["ran"] is True out = capsys.readouterr().out - assert "did not restart running gateways" in out + assert "Already up to date!" in out -def test_already_up_to_date_skips_restart_when_nothing_pending( - monkeypatch, tmp_path, capsys -): - args = _update_args() - _patch_update_deps(monkeypatch, tmp_path, _make_up_to_date_side_effect()) - - seen = {"ran": False} - monkeypatch.setattr( - update_cmd, - "_run_pending_fleet_restart", - lambda: seen.__setitem__("ran", True) or True, - ) - monkeypatch.setattr( - update_cmd_fleet, - "_run_pending_fleet_restart", - lambda: seen.__setitem__("ran", True) or True, - ) - - hermes_main.cmd_update(args) - - assert seen["ran"] is False - assert "did not restart running gateways" not in capsys.readouterr().out def test_startup_warn_prints_when_marker_present(capsys): diff --git a/tests/hermes_cli/test_update_handoff_desktop_rebuild.py b/tests/hermes_cli/test_update_handoff_desktop_rebuild.py deleted file mode 100644 index 1ea5211084..0000000000 --- a/tests/hermes_cli/test_update_handoff_desktop_rebuild.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The current-checkout repair path must rebuild the Desktop app (#97343). - -A retry with no new commits must still prepare the Desktop product selected -before the update. Failure must stop before configuration and success reporting. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from hermes_cli import update_cmd - - -def test_current_checkout_repair_rebuilds_desktop_under_project_root(tmp_path): - """The retry preserves the pre-update desktop selection and checkout root.""" - completion = MagicMock(return_value=True) - with ( - patch.object(update_cmd, "_prepare_updated_checkout") as prepare, - patch.object(update_cmd, "_m") as m, - patch.object(update_cmd, "_check_and_apply_config_migration"), - patch.object(update_cmd, "_print_verified_update_completion", completion), - ): - m.return_value.PROJECT_ROOT = tmp_path - complete = update_cmd._repair_current_checkout( - assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None, - had_desktop_app_before_update=True, upstream_checked=True, - ) - - assert complete is True - prepare.assert_called_once_with(tmp_path, desktop=True) - completion.assert_called_once_with("✓ Already up to date!") - - -def test_failed_desktop_rebuild_withholds_success_completion(tmp_path): - """A failed build propagates before config migration or success reporting.""" - completion = MagicMock(return_value=True) - with ( - patch.object(update_cmd, "_m") as m, - patch.object(update_cmd, "_check_and_apply_config_migration") as migrate, - patch.object(update_cmd, "_prepare_updated_checkout", side_effect=RuntimeError("desktop build failed")), - patch.object(update_cmd, "_print_verified_update_completion", completion), - ): - m.return_value.PROJECT_ROOT = tmp_path - with pytest.raises(RuntimeError, match="desktop build failed"): - update_cmd._repair_current_checkout( - assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None, - had_desktop_app_before_update=True, upstream_checked=True, - ) - - migrate.assert_not_called() - completion.assert_not_called() diff --git a/tests/hermes_cli/test_update_products.py b/tests/hermes_cli/test_update_products.py index a5562a5021..a9981e5c3c 100644 --- a/tests/hermes_cli/test_update_products.py +++ b/tests/hermes_cli/test_update_products.py @@ -11,60 +11,8 @@ import pm from hermes_cli import main, update_cmd -def test_current_checkout_dependency_failure_prevents_completion(tmp_path, monkeypatch): - monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - - monkeypatch.setattr(update_cmd, "_print_verified_update_completion", lambda *a: pytest.fail("reported completion")) - - def fail_sync(*args, **kwargs): - raise pm.InstallError("venv", "dependency conflict") - - monkeypatch.setattr(pm, "sync_venv", fail_sync) - with pytest.raises(pm.InstallError, match="dependency conflict"): - update_cmd._repair_current_checkout( - assume_yes=True, gateway_mode=False, pre_update_snapshot_id=None, - had_desktop_app_before_update=False, upstream_checked=True, - ) -def test_build_runs_in_selected_python_and_propagates_failure(tmp_path, monkeypatch): - from hermes_cli.update_cmd_maint import _prepare_updated_checkout - from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path - from hermes_constants import venv_python_path - - root = tmp_path / "checkout" - package = root / "hermes_cli" - package.mkdir(parents=True) - (package / "__init__.py").write_text("") - (package / "source_build.py").write_text( - "import json, os, pathlib, sys\n" - "pathlib.Path('build-process.json').write_text(json.dumps({" - "'python': sys.executable, 'argv': sys.argv[1:], 'path': sys.path}))\n" - "raise SystemExit(23)\n" - ) - calls = [] - selected = install_state_dir(root) / "environments/selected/venv" - # A stale repo-local venv must not win over PM's selected generation. - venv.EnvBuilder(with_pip=False).create(root / "venv") - - def sync(*args, **kwargs): - calls.append((args, kwargs)) - # Publication creates the interpreter the next process must use. - venv.EnvBuilder(with_pip=False).create(selected) - pm.Facts(runtime_facts_path(root)).record_state("venv", "prepared", [], environment=selected) - - monkeypatch.setattr(pm, "sync_venv", sync) - monkeypatch.setenv("PYTHONPATH", str(tmp_path / "obsolete-deps")) - with pytest.raises(subprocess.CalledProcessError) as error: - _prepare_updated_checkout(root, desktop=True) - assert error.value.returncode == 23 - assert calls == [((), {"explicit": True, "project_root": root})] - record = json.loads((root / "build-process.json").read_text()) - assert record["python"] == str(venv_python_path(selected)) - assert record["argv"] == ["--source", str(root), "--desktop"] - assert str(tmp_path / "obsolete-deps") not in record["path"] - assert not (root / ".update-incomplete").exists() - assert not (root / ".lazy-refresh-incomplete").exists() @pytest.mark.parametrize("failure", [ diff --git a/tests/hermes_cli/test_update_sqlite_remediation.py b/tests/hermes_cli/test_update_sqlite_remediation.py index 79b110eaf1..3843dbe0b9 100644 --- a/tests/hermes_cli/test_update_sqlite_remediation.py +++ b/tests/hermes_cli/test_update_sqlite_remediation.py @@ -25,6 +25,17 @@ def test_runtime_status_probes_running_venv_outside_checkout(tmp_path, monkeypat assert info is vulnerable +def test_runtime_status_uses_selected_python_not_legacy_repo_venv(tmp_path, monkeypatch): + selected = tmp_path / "selected/bin/python" + monkeypatch.setattr("hermes_constants.project_venv_dir", lambda root: tmp_path / "obsolete-venv") + monkeypatch.setattr(update_cmd.sys, "executable", str(selected)) + observed = [] + safe = SimpleNamespace(wal_reset_vulnerable=False) + monkeypatch.setattr("hermes_cli.sqlite_runtime.probe_sqlite_runtime", lambda python: observed.append(Path(python)) or safe) + assert update_cmd._post_update_sqlite_runtime_status() == (True, safe) + assert observed == [selected] + + def test_summary_withholds_success_when_sqlite_remediation_failed(capsys, monkeypatch): monkeypatch.setattr( update_cmd, @@ -80,22 +91,3 @@ def test_current_checkout_completion_is_verified_before_success(capsys, monkeypa assert complete is False assert "Already up to date" not in out assert "SQLite 3.46.1" in out - - -def test_current_checkout_repair_returns_verified_completion_result(monkeypatch): - monkeypatch.setattr(update_cmd, "_prepare_updated_checkout", lambda *a, **k: None) - monkeypatch.setattr(update_cmd, "_check_and_apply_config_migration", lambda **k: None) - monkeypatch.setattr( - update_cmd, - "_print_verified_update_completion", - lambda _message: False, - ) - complete = update_cmd._repair_current_checkout( - assume_yes=True, - gateway_mode=False, - pre_update_snapshot_id=None, - had_desktop_app_before_update=False, - upstream_checked=True, - ) - - assert complete is False diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index f76258f919..257568a019 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -843,75 +843,3 @@ class TestCmdlineCapture: """ live = self._live() assert main_dashboard._dashboard_cmdline_for_pid(123) is None - - -class TestPostUpdateStaleModuleReload: - """Regression tests for the post-update stale-module ImportError. - - ``hermes update`` runs in the PRE-pull Python process. When the update - adds a new symbol to ``hermes_cli._subprocess_compat`` (as #87134 added - ``bounded_probe_run``), the post-update dashboard cleanup's lazy - ``from hermes_cli._subprocess_compat import bounded_probe_run`` hits the - stale cached module and crashes with ImportError — after the code update - itself already succeeded. The cleanup entry point must force-reload the - process-scan modules first (PR #87757 + ZIP-path widening). - """ - - def test_cleanup_reloads_before_scanning(self): - """Dashboard refresh must reload the process-scan - modules BEFORE calling _kill_stale_dashboard_processes, on every - call path (git update and ZIP fallback both route here).""" - from hermes_cli import update_cmd - - order: list[str] = [] - with patch.object( - update_cmd, "_reload_process_scan_modules", - side_effect=lambda: order.append("reload"), - ), patch( - "hermes_cli.main._kill_stale_dashboard_processes", - side_effect=lambda **kw: order.append("kill") or {"unrecovered": []}, - ): - update_cmd_maint._refresh_dashboard_after_update() - - assert order == ["reload", "kill"] - - def test_reload_restores_missing_symbol(self): - """Simulate the stale-module state: strip ``bounded_probe_run`` off - the cached module object (what an old pre-#87134 module looks like) - and verify the reload restores it from disk — the exact state the - Windows update crash came from.""" - import hermes_cli._subprocess_compat as compat - from hermes_cli import update_cmd - - assert hasattr(compat, "bounded_probe_run") - try: - delattr(compat, "bounded_probe_run") - assert not hasattr(compat, "bounded_probe_run") - - update_cmd._reload_process_scan_modules() - - stale = sys.modules["hermes_cli._subprocess_compat"] - assert hasattr(stale, "bounded_probe_run") - finally: - importlib.reload(sys.modules["hermes_cli._subprocess_compat"]) - importlib.reload(sys.modules["hermes_cli.dashboard_procs"]) - - def test_reload_failure_is_nonfatal(self): - """A reload failure must log and continue, never raise — the cleanup - step runs after the update already succeeded.""" - from hermes_cli import update_cmd - - with patch("importlib.reload", side_effect=RuntimeError("boom")): - update_cmd._reload_process_scan_modules() # must not raise - - def test_config_reload_list_includes_process_scan_modules(self): - """PR #87757's half: the git-path pre-cleanup reload also refreshes - the process-scan modules (belt to the entry-point suspenders).""" - from hermes_cli import update_cmd - - reloaded: list[str] = [] - with patch("importlib.reload", side_effect=lambda m: reloaded.append(m.__name__)): - update_cmd._reload_config_modules() - - assert "hermes_cli._subprocess_compat" in reloaded - assert "hermes_cli.dashboard_procs" in reloaded diff --git a/tests/hermes_cli/test_update_stale_module_purge.py b/tests/hermes_cli/test_update_stale_module_purge.py deleted file mode 100644 index d722280235..0000000000 --- a/tests/hermes_cli/test_update_stale_module_purge.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Tests for _purge_stale_hermes_modules — the class fix for stale -sys.modules breaking the gateway auto-restart after `hermes update`. - -Field failure (2026-08-20, Teknium's Linux box): `hermes update` pulled a -checkout where hermes_cli/gateway.py newly imports `line_input` from -hermes_cli.cli_output, but the updater process had cli_output cached from -before that symbol existed. The function-level `from hermes_cli.gateway -import ...` in the restart phase raised ImportError, the whole phase -aborted, and the running gateway kept serving pre-update code. - -The old mitigation (_UPDATE_RUNTIME_RELOAD_MODULES) reloaded 3 hardcoded -modules — re-fixed per symptom. The purge evicts EVERY cached module under -the Hermes package prefixes so later imports rebuild a self-consistent -module graph from the updated checkout. -""" - -from __future__ import annotations - -import importlib -import json -import sys -import types - -import pytest - -from hermes_cli import main as cli_main -from hermes_cli import update_cmd - - -@pytest.fixture(autouse=True) -def _restore_sys_modules(): - """Snapshot & restore sys.modules around each test. - - The purge under test evicts real Hermes modules from the cache; later - tests in the same process may hold references to the evicted module - objects (e.g. `patch.object` targets), so put the originals back. - """ - snapshot = dict(sys.modules) - yield - for name, mod in snapshot.items(): - sys.modules[name] = mod - for name in list(sys.modules): - if name not in snapshot: - del sys.modules[name] - - -def _fake_module(name: str) -> types.ModuleType: - mod = types.ModuleType(name) - mod.__stale_sentinel__ = True - return mod - - -def test_purge_evicts_hermes_prefixed_modules(): - victims = [ - "hermes_cli.cli_output", - "hermes_cli.gateway", - "gateway.status", - "tools.ansi_strip", - "tui_gateway.server", - "agent.memory_store", - ] - added = [] - for name in victims: - if name not in sys.modules: - sys.modules[name] = _fake_module(name) - added.append(name) - try: - cli_main._purge_stale_hermes_modules() - for name in victims: - mod = sys.modules.get(name) - assert mod is None or not getattr(mod, "__stale_sentinel__", False), ( - f"{name} survived the purge" - ) - finally: - for name in added: - sys.modules.pop(name, None) - - -def test_purge_protects_executing_modules(): - # The updater's own modules must survive — they're running this code. - cli_main._purge_stale_hermes_modules() - assert sys.modules.get("hermes_cli.update_cmd") is update_cmd - assert sys.modules.get("hermes_cli.main") is cli_main - assert "hermes_cli" in sys.modules - - -def test_purge_preserves_active_update_receipt(tmp_path, monkeypatch): - """A receipt begun before the post-pull purge must still be finalizable.""" - import hermes_cli.update_receipt as receipt - - receipt_dir = tmp_path / "update_receipts" - monkeypatch.setattr(receipt, "_receipt_dir", lambda: receipt_dir) - token = receipt._current.set(None) - post_purge_receipt = receipt - try: - receipt.begin_update_receipt() - receipt.record_step("git_pull", True, "updated checkout") - - cli_main._purge_stale_hermes_modules() - post_purge_receipt = importlib.import_module("hermes_cli.update_receipt") - path = post_purge_receipt.finalize_update_receipt("success") - - assert path is not None and path.is_file() - latest = json.loads((receipt_dir / "latest.json").read_text(encoding="utf-8")) - assert latest["outcome"] == "success" - assert latest["steps"][0]["name"] == "git_pull" - finally: - receipt._current.reset(token) - - -def test_purge_leaves_prefix_lookalikes_alone(): - # `gateway_foo` starts with the string prefix "gateway" but is NOT the - # gateway package — the root-segment check must spare it. - lookalikes = ["gatewayd", "toolshed", "agents_external"] - added = [] - for name in lookalikes: - if name not in sys.modules: - sys.modules[name] = _fake_module(name) - added.append(name) - try: - cli_main._purge_stale_hermes_modules() - for name in lookalikes: - assert name in sys.modules, f"{name} was wrongly purged" - finally: - for name in added: - sys.modules.pop(name, None) - - -def test_purge_never_raises_on_weird_sys_modules(): - # Entries with None values (import machinery quirk) must not break it. - sys.modules["hermes_cli._purge_test_none"] = None # type: ignore[assignment] - try: - cli_main._purge_stale_hermes_modules() - finally: - sys.modules.pop("hermes_cli._purge_test_none", None) - - -def test_stale_symbol_scenario_end_to_end(): - """Reproduce the field failure shape: a cached module missing a symbol - that freshly-imported code needs — purge, then re-import resolves it.""" - name = "hermes_cli.cli_output" - real = sys.modules.get(name) - # Install a stale stand-in WITHOUT line_input (pre-d0132b582 world). - stale = types.ModuleType(name) - sys.modules[name] = stale - try: - # The failure mode: importing the symbol from the stale cache dies. - try: - from hermes_cli.cli_output import line_input # noqa: F401 - raised = False - except ImportError: - raised = True - assert raised, "precondition: stale module must lack line_input" - - cli_main._purge_stale_hermes_modules() - - # Post-purge, the import resolves against real on-disk source. - from hermes_cli.cli_output import line_input # noqa: F401 - finally: - sys.modules.pop(name, None) - if real is not None: - sys.modules[name] = real - - -def test_purge_keeps_plan_record_class_identity(): - # The pre-update plan is built BEFORE the purge; reconciliation after it filters with - # ``isinstance(r, RuntimeRecord)``. An evicted ``update_inventory`` yields a fresh class, - # every record fails the check, and the plan-vs-execution report goes silently empty. - from hermes_cli.update_inventory import RuntimeRecord as before - - cli_main._purge_stale_hermes_modules() - from hermes_cli.update_inventory import RuntimeRecord as after - assert after is before diff --git a/tests/hermes_cli/test_update_state_autorestore.py b/tests/hermes_cli/test_update_state_autorestore.py index 7b54340835..7be1c75aad 100644 --- a/tests/hermes_cli/test_update_state_autorestore.py +++ b/tests/hermes_cli/test_update_state_autorestore.py @@ -98,6 +98,12 @@ def snapshot_db(tmp_path): return snapshot +@pytest.fixture(autouse=True) +def _isolate_database_holders(monkeypatch): + # These fixtures own all DB connections. Do not scan other users' /proc FDs. + monkeypatch.setattr("hermes_cli.backup_restore._foreign_db_holder_pids", lambda path: []) + + def _row_count(db_path: Path) -> int: conn = sqlite3.connect(db_path) try: diff --git a/tests/hermes_cli/test_update_zip_sync_failure.py b/tests/hermes_cli/test_update_zip_sync_failure.py index b8d9636920..e47661677b 100644 --- a/tests/hermes_cli/test_update_zip_sync_failure.py +++ b/tests/hermes_cli/test_update_zip_sync_failure.py @@ -1,102 +1,30 @@ -"""A failed dependency transaction must stop the ZIP update, not report success.""" +"""ZIP and Git-error fallback return the completion owner's exact failure.""" from types import SimpleNamespace import subprocess -from unittest.mock import patch +from unittest.mock import Mock import pytest -import pm -import hermes_cli.main as main -from hermes_cli import update_cmd, update_cmd_maint, update_cmd_zip +from hermes_cli import main, update_cmd, update_cmd_zip -def test_zip_dependency_failure_propagates_before_followup_mutations(tmp_path, monkeypatch): +@pytest.mark.parametrize("route", ["zip", "git-error"]) +def test_zip_completion_failure_does_not_run_old_followup(tmp_path, monkeypatch, route): monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - with ( - patch.object(update_cmd_zip, "_abort_zip_update_if_dirty_tree"), - patch.object(update_cmd_zip, "_download_and_swap_zip"), - patch.object(update_cmd_maint, "_sweep_bytecode_after_update"), - patch.object( - update_cmd_maint, "_prepare_updated_checkout", - side_effect=pm.InstallError("venv", "network unavailable"), - ) as prepare, - patch.object(update_cmd_maint, "_print_bundled_skills_sync_report") as skills, - patch.object(update_cmd_maint, "_print_verified_update_completion") as summary, - ): - with pytest.raises(pm.InstallError, match="network unavailable"): - update_cmd_zip._update_via_zip(SimpleNamespace(branch="main")) - - prepare.assert_called_once_with(tmp_path, desktop=False) - skills.assert_not_called() - summary.assert_not_called() - - -def test_pull_dependency_failure_keeps_recovery_marker(tmp_path, monkeypatch): - monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - post_pull_sha = "b" * 40 - with ( - patch.object(update_cmd, "_verify_head_after_pull", return_value=post_pull_sha), - patch.object(update_cmd, "_sweep_bytecode_after_update"), - patch.object( - update_cmd, "_prepare_updated_checkout", - side_effect=pm.InstallError("venv", "sync stopped"), - ) as prepare, - patch.object(update_cmd, "_run_post_update_maintenance") as maintenance, - patch.object(update_cmd, "_restart_gateway_fleet_after_update") as restart, - ): - with pytest.raises(pm.InstallError, match="sync stopped"): - update_cmd._apply_pulled_update( - ["git"], "main", "a" * 40, SimpleNamespace(in_place_update=False), - SimpleNamespace(assume_yes=True, gw_input_fn=None), - gateway_mode=False, is_fork=False, desktop_dir=tmp_path / "apps" / "desktop", - had_desktop_app_before_update=False, pre_update_snapshot_id=None, - _pre_update_plan=None, _windows_gateway_resume=[], - ) - - prepare.assert_called_once_with(tmp_path, desktop=False) - marker = update_cmd._fleet_restart_pending_marker_path() - assert f"expected_sha={post_pull_sha}" in marker.read_text(encoding="utf-8") - maintenance.assert_not_called() - restart.assert_not_called() - - -@pytest.mark.parametrize("route", ["direct", "git-failure"]) -@pytest.mark.parametrize("gateway_mode", [False, True]) -@pytest.mark.parametrize("complete", [False, True]) -def test_zip_callers_propagate_completion(tmp_path, monkeypatch, route, gateway_mode, complete): - monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path) - monkeypatch.setattr(update_cmd, "_update_via_zip", lambda *args, **kwargs: complete) - exit_markers = [] - monkeypatch.setattr(update_cmd, "_write_gateway_update_exit_code", exit_markers.append) - resumed = [] - args = SimpleNamespace(branch="main") - - if route == "direct": - monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *args: SimpleNamespace( - gw_input_fn=None, assume_yes=True)) - monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda args: None) - monkeypatch.setattr(main, "_run_pre_update_backup", lambda args: None) - monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: None) - monkeypatch.setattr(main, "_resume_windows_gateways_after_update", resumed.append) - monkeypatch.setattr(main, "_desktop_packaged_executable", lambda root: None) - monkeypatch.setattr(main, "_desktop_dist_exists", lambda root: False) - monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, [], False)) - monkeypatch.setattr(update_cmd, "_source_update_channel", lambda args: "main") - - def invoke(): - update_cmd._cmd_update_impl(args, gateway_mode=gateway_mode) - else: - monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda error: True) - - def invoke(): + monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None) + monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", lambda *a: None) + monkeypatch.setattr(update_cmd, "_should_zip_fallback_on_update_error", lambda exc: True) + request = {"expected_sha": None, "desktop": True} + completion = Mock(side_effect=SystemExit(23)) + monkeypatch.setattr(update_cmd, "_complete_source_update", completion) + monkeypatch.setattr(update_cmd, "_run_post_update_maintenance", lambda **kw: pytest.fail("old maintenance")) + monkeypatch.setattr(update_cmd, "_update_via_zip", update_cmd_zip._update_via_zip) + with pytest.raises(SystemExit) as error: + if route == "zip": + update_cmd_zip._update_via_zip(SimpleNamespace(branch="main"), completion_request=request) + else: update_cmd._handle_update_called_process_error( - subprocess.CalledProcessError(1, ["git", "fetch"]), args, gateway_mode, False) - - if complete: - invoke() - else: - with pytest.raises(SystemExit) as failure: - invoke() - assert failure.value.code == 1 - assert exit_markers == ([complete] if gateway_mode else []) - assert resumed == ([None] if route == "direct" else []) + subprocess.CalledProcessError(1, ["git", "fetch"]), SimpleNamespace(branch="main"), + False, True, completion_request=request) + assert error.value.code == 23 + completion.assert_called_once_with(request) diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 0304d3efe6..0dfd1fe5be 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -39,12 +39,17 @@ entry shims stop the old updater cleanly and ask for a relaunch instead of invok PM or falling back to pip. Completion belongs to the new launcher, not that mixed old-code/new-files process. -Current source updates use one PM sync for the recorded extras and enabled -plugins, then build frontend products in a fresh process on the selected -Python. A retry on an already-current checkout follows the same path. -Dependency or build failures stop completion; the updater does not retry -through pip, reinstall providers separately, or create incomplete markers. -Use `hermes pm repair` for damaged dependency files. +Current source updates hand the selected checkout to a fresh completion owner. +Its bootstrap Python disables site-package initialization before asking PM to +sync the recorded extras and enabled plugins. The selected Python then owns +frontend builds, profile/configuration maintenance, gateway restarts and runtime +verification. Git, already-current retries and ZIP fallback use this same path. +The original command keeps the update lock while waiting; a missing or failed +completion result cannot report success. Correlated PM failures remain in the +update receipt, and interrupted restarts retain their fleet obligation. +Dependency or build failures never retry through pip or a source re-download. +Use `hermes pm repair` for damaged dependency files. See the developer +[source completion ownership note](https://github.com/NousResearch/hermes-agent/blob/main/docs/source-update-completion.md). ## Source installs and packaged builds From 081fe8fcffed48249298129049c4931af4620807 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:10:46 -0400 Subject: [PATCH 22/33] Preserve selection compare-and-swap through the text plugin menu --- hermes_cli/plugins_cmd.py | 4 +- .../test_plugins_cmd_enable_disable_nested.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 2aaa9031be..3c057fbc73 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1986,7 +1986,7 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable except (ValueError, KeyboardInterrupt, EOFError): return print() - _save_plugin_selection_fallback(plugin_keys, chosen, disabled) + _save_plugin_selection_fallback(plugin_keys, chosen, disabled, expected_config=expected_config) if categories: print(color("\n Provider Plugins", Colors.YELLOW)) @@ -2004,7 +2004,7 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable print() -def _save_plugin_selection_fallback(plugin_keys, chosen, disabled) -> None: +def _save_plugin_selection_fallback(plugin_keys, chosen, disabled, *, expected_config=None) -> None: """The text fallback's save: same admission authority, refusal printed.""" from hermes_cli.plugins_admission import AdmissionRefused diff --git a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py index e0d221873b..62b4be831d 100644 --- a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py +++ b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py @@ -240,3 +240,43 @@ class TestCompositeMenuWritesCanonicalKey: saved_dis = mock_save_dis.call_args[0][0] assert "web/firecrawl" in saved_dis # canonical key persisted assert "web-firecrawl" not in saved_dis # never the bare name + + @pytest.mark.parametrize("config_changes", [False, True]) + def test_fallback_forwards_preinteraction_digest( + self, tmp_path, monkeypatch, config_changes, + ): + from hermes_cli import plugins_cmd as pc + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + initial_config = "plugins:\n enabled: [web/firecrawl]\n disabled: []\n" + config_path.write_text(initial_config, encoding="utf-8") + original_digest = pc._plugin_selection_version() + monkeypatch.setattr(pc, "_discover_all_plugins", lambda: [ + ("web-firecrawl", "1.0", "firecrawl", "bundled", None, "web/firecrawl"), + ]) + monkeypatch.setattr(pc, "_provider_categories", lambda: []) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + # Missing curses must use the real text-menu and persistence chain. + monkeypatch.setitem(sys.modules, "curses", None) + answers = iter(("1", "")) # uncheck the plugin, then confirm + + def respond(_prompt): + answer = next(answers) + if config_changes and answer == "1": + config_path.write_text( + initial_config + "model: edited-during-input\n", encoding="utf-8", + ) + return answer + + with patch("builtins.input", side_effect=respond), patch( + "hermes_cli.plugins_admission.admit_plugin_set_change", + ) as admit: + pc.cmd_toggle() + + admit.assert_called_once_with( + set(), {"web/firecrawl"}, active_plugins_dir=tmp_path / "plugins", + extra_dirs=(), expected_config=original_digest, + ) + if config_changes: + assert pc._plugin_selection_version() != original_digest From 4f7522c418986b9f44473302c91225b7414e619e Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:12:17 -0400 Subject: [PATCH 23/33] Isolate handoff marker tests from the developer home --- apps/desktop/electron/update-handoff-marker.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/update-handoff-marker.test.ts b/apps/desktop/electron/update-handoff-marker.test.ts index a8f48e6a38..66934d5351 100644 --- a/apps/desktop/electron/update-handoff-marker.test.ts +++ b/apps/desktop/electron/update-handoff-marker.test.ts @@ -24,8 +24,8 @@ function markerStartedAt(home: string): number { return Number.parseInt(startedAt, 10) } -function runPosix(installRoot: string, startedAt?: string) { - const env = { ...process.env } +function runPosix(installRoot: string, startedAt?: string): ReturnType { + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_HOME: path.dirname(installRoot) } if (startedAt === undefined) { delete env.HERMES_UPDATE_STARTED_AT @@ -33,14 +33,14 @@ function runPosix(installRoot: string, startedAt?: string) { env.HERMES_UPDATE_STARTED_AT = startedAt } - return spawnSync('/bin/bash', [POSIX_SCRIPT, '--daemonized', '--install-root', installRoot, '--self-test-marker'], { + return spawnSync('bash', [POSIX_SCRIPT, '--daemonized', '--install-root', installRoot, '--self-test-marker'], { env, encoding: 'utf8' }) } -function runWindows(installRoot: string, startedAt?: string) { - const env = { ...process.env } +function runWindows(installRoot: string, startedAt?: string): ReturnType { + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_HOME: path.dirname(installRoot) } if (startedAt === undefined) { delete env.HERMES_UPDATE_STARTED_AT From eb7f032cc7f9799453a6c440a2ce9000cbd654f5 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:11:09 -0400 Subject: [PATCH 24/33] Use one scoped query owner for local models --- apps/desktop/src/api/client.ts | 30 +- .../src/api/local-models-owner.test.ts | 24 + apps/desktop/src/api/local-models.ts | 92 ++-- .../local-model-download-progress.test.tsx | 8 +- .../local-model-download-progress.tsx | 24 +- .../settings/local-models-settings.test.tsx | 128 +++-- .../app/settings/local-models-settings.tsx | 303 +++++------ .../model-catalog-menu.search-fold.test.tsx | 2 - .../src/app/shell/model-catalog-menu.test.tsx | 49 +- .../src/app/shell/model-catalog-menu.tsx | 102 ++-- .../src/components/model-picker.test.tsx | 45 +- apps/desktop/src/components/model-picker.tsx | 104 ++-- .../src/store/local-models-query.test.ts | 93 ++++ .../src/store/local-models-surfaces.test.tsx | 409 ++++++++++++++ .../src/store/local-runtime-jobs.test.ts | 84 ++- apps/desktop/src/store/local-runtime-jobs.ts | 506 ++++++++++++------ 16 files changed, 1438 insertions(+), 565 deletions(-) create mode 100644 apps/desktop/src/api/local-models-owner.test.ts create mode 100644 apps/desktop/src/store/local-models-query.test.ts create mode 100644 apps/desktop/src/store/local-models-surfaces.test.tsx diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts index b61f0d33b4..256feb1064 100644 --- a/apps/desktop/src/api/client.ts +++ b/apps/desktop/src/api/client.ts @@ -1,4 +1,5 @@ import { JsonRpcGatewayClient } from '@hermes/shared' +import { map, type MapStore } from 'nanostores' import type { HermesApiRequest } from '@/global' @@ -44,14 +45,20 @@ export class HermesGateway extends JsonRpcGatewayClient { // REST handlers accept profile reuse the primary dashboard via ?profile=; // unscoped handlers retain a profile backend. Remote overrides still route to // their owning backend. Null → primary, so single-profile users are unaffected. -let _apiProfile: null | string = null +interface ApiRequestScope { + profile: string | null + connectionId: string | null +} + +// This is the request authority, not a second copy in a presentation store. +export const $apiRequestScope: MapStore = map({ profile: null, connectionId: null }) export function setApiRequestProfile(profile: null | string): void { - _apiProfile = profile || null + $apiRequestScope.setKey('profile', profile || null) } export function profileScoped(profile?: null | string): { profile?: string } { - const selected = profile === undefined ? _apiProfile : profile + const selected = profile === undefined ? $apiRequestScope.get().profile : profile return selected ? { profile: selected } : {} } @@ -60,7 +67,7 @@ export function profileScoped(profile?: null | string): { profile?: string } { * Read-only twin of setApiRequestProfile for modules (e.g. voice playback) * that build their own connection URLs and must stay on the same backend. */ export function getApiRequestProfile(): null | string { - return _apiProfile + return $apiRequestScope.get().profile } // Registry connection serving the active gateway (null → the local pool). @@ -69,11 +76,10 @@ export function getApiRequestProfile(): null | string { // that dial their own backend (pluginSocket) resolve it through the SAME // source of truth those paths maintain for $connection. That makes the plugin // socket follow registry-agent activations too, not just profile switches. -// Same no-store-import contract as _apiProfile (avoids a cycle). -let _apiConnectionId: null | string = null +// Same no-store-import contract as profile scope (avoids a cycle). export function setApiRequestConnection(connectionId: null | string): void { - _apiConnectionId = connectionId || null + $apiRequestScope.setKey('connectionId', connectionId || null) } // Registry connection scope for a REST request. A registered remote gateway @@ -83,11 +89,13 @@ export function setApiRequestConnection(connectionId: null | string): void { // resolves to no tag, keeping single-source users byte-identical; explicit // 'local' must remain tagged when the legacy primary points elsewhere. export function connectionScoped(): { connectionId?: string } { - return _apiConnectionId ? { connectionId: _apiConnectionId } : {} + const connectionId: string | null = $apiRequestScope.get().connectionId + + return connectionId ? { connectionId } : {} } // Whether the window's primary connection is the local pool. Pushed from -// store/session's setConnection (same no-store-import contract as _apiProfile) +// store/session's setConnection (same no-store-import contract as profile scope) // so api/ helpers can name the backend an UNTAGGED request lands on without // importing the heavy session store — which would close a module cycle // through @/hermes. @@ -102,7 +110,7 @@ export function setApiRequestLocalMode(local: boolean): void { * never send this as a request pin (an explicit `'local'` bypasses Electron's * legacy per-profile remote overrides). */ export function ambientOwnerConnectionId(): string | undefined { - return _apiConnectionId ?? (_apiLocalMode ? 'local' : undefined) + return $apiRequestScope.get().connectionId ?? (_apiLocalMode ? 'local' : undefined) } /** Send a REST request to the renderer's active registry source. Request-level @@ -175,5 +183,5 @@ export function profileScopeKey(scope?: ProfileScope): string { /** Registry connection id that connection-scoped WS calls should target * (null → the local pool). Read-only twin of setApiRequestConnection. */ export function getApiRequestConnection(): null | string { - return _apiConnectionId + return $apiRequestScope.get().connectionId } diff --git a/apps/desktop/src/api/local-models-owner.test.ts b/apps/desktop/src/api/local-models-owner.test.ts new file mode 100644 index 0000000000..3ee2149da5 --- /dev/null +++ b/apps/desktop/src/api/local-models-owner.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import { setApiRequestConnection, setApiRequestProfile } from './client' +import { getLocalModelsJobs, getLocalModelsStatus, pauseLocalDownload } from './local-models' + +beforeEach((): void => { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api: vi.fn().mockResolvedValue({ jobs: [] }) } + }) + setApiRequestConnection('foreground') + setApiRequestProfile('default') +}) + +it('pins delayed reads and controls to their captured connection and profile', async (): Promise => { + const owner = { connectionId: 'background', profile: 'work' } + await getLocalModelsStatus(owner) + await getLocalModelsJobs(owner) + await pauseLocalDownload('download', owner) + + for (const [request] of vi.mocked(window.hermesDesktop.api).mock.calls) { + expect(request).toMatchObject(owner) + } +}) diff --git a/apps/desktop/src/api/local-models.ts b/apps/desktop/src/api/local-models.ts index 7a31ccda0b..490199573e 100644 --- a/apps/desktop/src/api/local-models.ts +++ b/apps/desktop/src/api/local-models.ts @@ -2,33 +2,41 @@ import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJ import { hermesApi, profileScoped } from './client' +export interface LocalModelsScope { + connectionId: string | null + profile: string +} + // The desktop surface of the managed llama.cpp runtime: status/catalog // reads, download/install/activate jobs, and server control. -export function getLocalModelsStatus(): Promise { +export function getLocalModelsStatus(scope?: LocalModelsScope): Promise { return hermesApi({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: '/api/local-models/status' }) } -export function getLocalHardware(): Promise { +export function getLocalHardware(scope?: LocalModelsScope): Promise { return hermesApi({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: '/api/local-models/hardware' }) } -export function getLocalCatalog(): Promise<{ models: LocalCatalogModel[] }> { +export function getLocalCatalog(scope?: LocalModelsScope): Promise<{ models: LocalCatalogModel[] }> { return hermesApi<{ models: LocalCatalogModel[] }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: '/api/local-models/catalog' }) } -export function installLocalRuntime(backend?: string): Promise<{ backend: string; job_id: string; tag: string }> { +export function installLocalRuntime( + backend?: string, + scope?: LocalModelsScope +): Promise<{ backend: string; job_id: string; tag: string }> { return hermesApi<{ backend: string; job_id: string; tag: string }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { backend: backend ?? null }, method: 'POST', path: '/api/local-models/runtime/install' @@ -44,42 +52,45 @@ export interface QuickstartResponse { needs_runtime: boolean } -export function quickstartLocalModels(modelId?: string): Promise { +export function quickstartLocalModels(modelId?: string, scope?: LocalModelsScope): Promise { return hermesApi({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { model_id: modelId ?? null }, method: 'POST', path: '/api/local-models/quickstart' }) } -export function downloadLocalModel(modelId: string): Promise<{ already_downloaded?: boolean; job_id: null | string }> { +export function downloadLocalModel( + modelId: string, + scope?: LocalModelsScope +): Promise<{ already_downloaded?: boolean; job_id: null | string }> { return hermesApi<{ already_downloaded?: boolean; job_id: null | string }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { model_id: modelId }, method: 'POST', path: '/api/local-models/download' }) } -export function deleteLocalModel(modelId: string): Promise<{ ok: boolean }> { +export function deleteLocalModel(modelId: string, scope?: LocalModelsScope): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), method: 'DELETE', path: `/api/local-models/models/${encodeURIComponent(modelId)}` }) } -export function getLocalRuntimeJob(jobId: string): Promise { +export function getLocalRuntimeJob(jobId: string, scope?: LocalModelsScope): Promise { return hermesApi({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: `/api/local-models/jobs/${encodeURIComponent(jobId)}` }) } -export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> { +export function getLocalModelsJobs(scope?: LocalModelsScope): Promise<{ jobs: LocalRuntimeJob[] }> { return hermesApi<{ jobs: LocalRuntimeJob[] }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: '/api/local-models/jobs' }) } @@ -88,45 +99,48 @@ export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> { // install/update, HF-browsed). The backend answers {ok, paused} / // {ok, resumed} — a false flag (no live download handle, e.g. a // quickstart engine leg) is reported to the caller, not treated as success. -export function pauseLocalDownload(jobId: string): Promise<{ ok: boolean; paused: boolean }> { +export function pauseLocalDownload(jobId: string, scope?: LocalModelsScope): Promise<{ ok: boolean; paused: boolean }> { return hermesApi<{ ok: boolean; paused: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { job_id: jobId }, method: 'POST', path: '/api/local-models/download/pause' }) } -export function resumeLocalDownload(jobId: string): Promise<{ ok: boolean; resumed: boolean }> { +export function resumeLocalDownload( + jobId: string, + scope?: LocalModelsScope +): Promise<{ ok: boolean; resumed: boolean }> { return hermesApi<{ ok: boolean; resumed: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { job_id: jobId }, method: 'POST', path: '/api/local-models/download/resume' }) } -export function activateLocalModel(modelId: string): Promise<{ job_id: string }> { +export function activateLocalModel(modelId: string, scope?: LocalModelsScope): Promise<{ job_id: string }> { return hermesApi<{ job_id: string }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { model_id: modelId }, method: 'POST', path: '/api/local-models/activate' }) } -export function ejectLocalModel(modelId: string): Promise<{ ok: boolean }> { +export function ejectLocalModel(modelId: string, scope?: LocalModelsScope): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { model_id: modelId }, method: 'POST', path: '/api/local-models/eject' }) } -export function setLocalServer(action: 'start' | 'stop'): Promise<{ ok: boolean }> { +export function setLocalServer(action: 'start' | 'stop', scope?: LocalModelsScope): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { action }, method: 'POST', path: '/api/local-models/server' @@ -150,26 +164,31 @@ export interface HFFileGroup { fit: 'fits-gpu' | 'needs-ram' | 'too-big' | 'unknown' } -export function searchHFModels(q: string, limit = 20): Promise<{ hits: HFSearchHit[] }> { +export function searchHFModels( + q: string, + limit: number = 20, + scope?: LocalModelsScope +): Promise<{ hits: HFSearchHit[] }> { return hermesApi<{ hits: HFSearchHit[] }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: `/api/local-models/search?q=${encodeURIComponent(q)}&limit=${limit}` }) } -export function listHFRepoFiles(repo: string): Promise<{ files: HFFileGroup[] }> { +export function listHFRepoFiles(repo: string, scope?: LocalModelsScope): Promise<{ files: HFFileGroup[] }> { return hermesApi<{ files: HFFileGroup[] }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), path: `/api/local-models/search/files?repo=${encodeURIComponent(repo)}` }) } export function downloadBrowsedModel( repo: string, - paths: string[] + paths: string[], + scope?: LocalModelsScope ): Promise<{ already_downloaded?: boolean; job_id: null | string; model_id: string }> { return hermesApi<{ already_downloaded?: boolean; job_id: null | string; model_id: string }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { paths, repo }, method: 'POST', path: '/api/local-models/download-browsed' @@ -177,10 +196,11 @@ export function downloadBrowsedModel( } export function sideloadLocalModel( - path: string + path: string, + scope?: LocalModelsScope ): Promise<{ already_present?: boolean; model_id: string; ok: boolean }> { return hermesApi<{ already_present?: boolean; model_id: string; ok: boolean }>({ - ...profileScoped(), + ...(scope ?? profileScoped()), body: { path }, method: 'POST', path: '/api/local-models/sideload' diff --git a/apps/desktop/src/app/settings/local-model-download-progress.test.tsx b/apps/desktop/src/app/settings/local-model-download-progress.test.tsx index 08decada98..269d49c352 100644 --- a/apps/desktop/src/app/settings/local-model-download-progress.test.tsx +++ b/apps/desktop/src/app/settings/local-model-download-progress.test.tsx @@ -2,7 +2,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/i18n' -import type * as RuntimeJobs from '@/store/local-runtime-jobs' import type * as Notifications from '@/store/notifications' import type { LocalRuntimeJob } from '@/types/hermes' @@ -16,8 +15,7 @@ vi.mock('@/store/notifications', async importOriginal => ({ notifyError: vi.fn() })) -vi.mock('@/store/local-runtime-jobs', async importOriginal => ({ - ...(await importOriginal()), +vi.mock('@/store/local-runtime-jobs', (): object => ({ watchLocalRuntimeJobs: vi.fn() })) @@ -71,7 +69,7 @@ describe('LocalModelDownloadActions', () => { await waitFor(() => { expect(pauseLocalDownload).toHaveBeenCalledTimes(1) }) - expect(pauseLocalDownload).toHaveBeenCalledWith('j1') + expect(pauseLocalDownload).toHaveBeenCalledWith('j1', undefined) expect(resumeLocalDownload).not.toHaveBeenCalled() }) @@ -105,7 +103,7 @@ describe('LocalModelDownloadActions', () => { await waitFor(() => { expect(resumeLocalDownload).toHaveBeenCalledTimes(1) }) - expect(resumeLocalDownload).toHaveBeenCalledWith('j1') + expect(resumeLocalDownload).toHaveBeenCalledWith('j1', undefined) expect(pauseLocalDownload).not.toHaveBeenCalled() }) diff --git a/apps/desktop/src/app/settings/local-model-download-progress.tsx b/apps/desktop/src/app/settings/local-model-download-progress.tsx index 7d3e8d8418..777d9f9622 100644 --- a/apps/desktop/src/app/settings/local-model-download-progress.tsx +++ b/apps/desktop/src/app/settings/local-model-download-progress.tsx @@ -1,11 +1,16 @@ -import { useState } from 'react' +import { type ReactElement, useState } from 'react' import { Button } from '@/components/ui/button' import { pauseLocalDownload, resumeLocalDownload } from '@/hermes' import { useI18n } from '@/i18n' import { Loader2, Pause, Play } from '@/lib/icons' import { cn } from '@/lib/utils' -import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' +import { + isCurrentLocalModelsOwner, + type LocalModelsOwner, + localModelsRequestScope, + watchLocalRuntimeJobs +} from '@/store/local-runtime-jobs' import { notifyError } from '@/store/notifications' import type { LocalRuntimeJob } from '@/types/hermes' @@ -91,7 +96,10 @@ export function LocalModelDownloadProgress({ job }: LocalModelDownloadProps) { ) } -export function LocalModelDownloadActions({ job }: LocalModelDownloadProps) { +export function LocalModelDownloadActions({ + job, + owner +}: LocalModelDownloadProps & { owner?: LocalModelsOwner }): ReactElement | null { const { t } = useI18n() const copy = t.settings.localModels const [busy, setBusy] = useState(false) @@ -105,14 +113,16 @@ export function LocalModelDownloadActions({ job }: LocalModelDownloadProps) { // below decides what the row shows; only a real transport failure // surfaces as an error. if (kind === 'pause') { - await pauseLocalDownload(job.job_id) + await pauseLocalDownload(job.job_id, owner ? localModelsRequestScope(owner) : undefined) } else { - await resumeLocalDownload(job.job_id) + await resumeLocalDownload(job.job_id, owner ? localModelsRequestScope(owner) : undefined) } - watchLocalRuntimeJobs() + watchLocalRuntimeJobs(owner) } catch (err) { - notifyError(err, copy.downloadFailed(job.target)) + if (!owner || isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.downloadFailed(job.target)) + } } finally { setBusy(false) } diff --git a/apps/desktop/src/app/settings/local-models-settings.test.tsx b/apps/desktop/src/app/settings/local-models-settings.test.tsx index bf84f1869a..1db6e8f480 100644 --- a/apps/desktop/src/app/settings/local-models-settings.test.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.test.tsx @@ -1,9 +1,22 @@ +vi.mock('@/store/profile', async (): Promise => { + const { atom } = await import('nanostores') + + return { $activeGatewayProfile: atom('default') } +}) +vi.mock('@/store/session', async (): Promise => { + const { atom } = await import('nanostores') + + return { $connection: atom(null), $defaultReasoningEffort: atom('') } +}) + +import { QueryClientProvider } from '@tanstack/react-query' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { I18nProvider } from '@/i18n' -import { $localRuntimeJobs, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' +import { queryClient } from '@/lib/query-client' +import { localModelsKey, localModelsOwner, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' import { LocalModelsSettings } from './local-models-settings' @@ -105,11 +118,13 @@ const REFUSED_MODEL: LocalCatalogModel = { function renderPane() { return render( - - - - - + + + + + + + ) } @@ -124,7 +139,8 @@ async function renderFullPane(): Promise> { // straight to the full pane, the runtime section directly. await waitFor((): void => { expect( - Boolean(screen.queryByRole('button', { name: /let me choose/i })) || screen.queryAllByText(/this machine/i).length > 0 + Boolean(screen.queryByRole('button', { name: /let me choose/i })) || + screen.queryAllByText(/this machine/i).length > 0 ).toBe(true) }) @@ -137,18 +153,23 @@ async function renderFullPane(): Promise> { return result } -beforeEach(() => { +beforeEach((): void => { + queryClient.clear() + queryClient.setDefaultOptions({ queries: { ...queryClient.getDefaultOptions().queries, retry: false } }) mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS) mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE) mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] }) // The backend mock ECHOES the atom: the watcher's immediate poll reads // seeded jobs instead of wiping them with a default {jobs:[]}. - mocked.getLocalModelsJobs.mockImplementation(async () => ({ jobs: [...$localRuntimeJobs.get()] })) - $localRuntimeJobs.set([]) + mocked.getLocalModelsJobs.mockImplementation(async () => ({ + jobs: [...(queryClient.getQueryData(localModelsKey(localModelsOwner(), 'jobs')) ?? [])] + })) + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), []) }) afterEach(async () => { cleanup() + queryClient.clear() mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] }) await act(async () => { watchLocalRuntimeJobs() @@ -318,7 +339,7 @@ describe('LocalModelsSettings', () => { }) // A running job already in the app-level store — as after closing and // reopening the pane mid-download. - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'j9', kind: 'model-download', @@ -351,7 +372,7 @@ describe('LocalModelsSettings', () => { runtime_installed: true, runtime_backend: 'cuda' }) - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'j2', kind: 'model-download', @@ -397,7 +418,7 @@ describe('quickstart', () => { }) it('pins the quickstart progress view while the job runs', async () => { - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'q1', kind: 'quickstart', @@ -470,12 +491,15 @@ describe('BrowseSection', () => { }) fireEvent.click(screen.getByRole('button', { name: /download ·/i })) await waitFor((): void => { - expect(mocked.downloadLocalModel).toHaveBeenCalledWith(SPILLED_MODEL.id) + expect(mocked.downloadLocalModel).toHaveBeenCalledWith(SPILLED_MODEL.id, { + connectionId: null, + profile: 'default' + }) }) mocked.activateLocalModel.mockResolvedValue({ job_id: 'explicit-spill' }) fireEvent.click(await screen.findByRole('button', { name: /^use$/i })) await waitFor((): void => { - expect(mocked.activateLocalModel).toHaveBeenCalledWith(stagedId) + expect(mocked.activateLocalModel).toHaveBeenCalledWith(stagedId, { connectionId: null, profile: 'default' }) }) expect(mocked.quickstartLocalModels).not.toHaveBeenCalled() }) @@ -495,11 +519,13 @@ describe('BrowseSection', () => { }) render( - - - - - + + + + + + + ) await act(async () => { await vi.runOnlyPendingTimersAsync() @@ -514,7 +540,7 @@ describe('BrowseSection', () => { await act(async () => { await vi.advanceTimersByTimeAsync(400) }) - expect(hermes.searchHFModels).toHaveBeenCalledWith('qwen') + expect(hermes.searchHFModels).toHaveBeenCalledWith('qwen', 20, { connectionId: null, profile: 'default' }) expect(screen.getByText('unsloth/Qwen3.8-27B-GGUF')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /show files/i })) @@ -534,7 +560,11 @@ describe('BrowseSection', () => { await act(async () => { await vi.runOnlyPendingTimersAsync() }) - expect(hermes.downloadBrowsedModel).toHaveBeenCalledWith('unsloth/Qwen3.8-27B-GGUF', ['Qwen3.8-27B-Q4_K_M.gguf']) + expect(hermes.downloadBrowsedModel).toHaveBeenCalledWith( + 'unsloth/Qwen3.8-27B-GGUF', + ['Qwen3.8-27B-Q4_K_M.gguf'], + { connectionId: null, profile: 'default' } + ) } finally { vi.useRealTimers() } @@ -597,33 +627,43 @@ describe('quickstart completion navigation', () => { // A finished quickstart already in history when the pane mounts — // must NOT trigger navigation. - $localRuntimeJobs.set([doneJob]) + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [doneJob]) render( - - - - - - + + + + + + + + ) await act(async () => {}) expect(routeProbe).not.toHaveBeenCalledWith('/') // A quickstart the pane SAW running that then completes -> navigate. const running: LocalRuntimeJob = { ...doneJob, job_id: 'live-run', phase: 'downloading', status: 'running' } - await act(async () => { - $localRuntimeJobs.set([doneJob, running]) + await act(async (): Promise => { + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [doneJob, running]) + await new Promise((resolve): void => { + setTimeout(resolve, 0) + }) }) await act(async () => { - $localRuntimeJobs.set([doneJob, { ...running, phase: 'done', status: 'done' }]) + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ + doneJob, + { ...running, phase: 'done', status: 'done' } + ]) }) - expect(routeProbe).toHaveBeenCalledWith('/') + await waitFor((): void => expect(routeProbe).toHaveBeenCalledWith('/')) }) }) describe('pause / resume integration', () => { - beforeEach(() => { + beforeEach((): void => { + queryClient.clear() + queryClient.setDefaultOptions({ queries: { ...queryClient.getDefaultOptions().queries, retry: false } }) vi.mocked(hermes.pauseLocalDownload).mockResolvedValue({ ok: true, paused: true }) vi.mocked(hermes.resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true }) }) @@ -634,7 +674,7 @@ describe('pause / resume integration', () => { runtime_installed: true, runtime_backend: 'cuda' }) - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'j1', kind: 'model-download', @@ -659,7 +699,7 @@ describe('pause / resume integration', () => { await waitFor(() => { expect(hermes.pauseLocalDownload).toHaveBeenCalledTimes(1) }) - expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('j1') + expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('j1', { connectionId: null, profile: 'default' }) expect(hermes.resumeLocalDownload).not.toHaveBeenCalled() }) @@ -669,7 +709,7 @@ describe('pause / resume integration', () => { runtime_installed: true, runtime_backend: 'cuda' }) - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'j1', kind: 'model-download', @@ -695,7 +735,7 @@ describe('pause / resume integration', () => { fireEvent.click(screen.getByRole('button', { name: /resume/i })) await waitFor(() => { - expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('j1') + expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('j1', { connectionId: null, profile: 'default' }) }) // The watcher re-kicked: an authoritative re-read happens after resume. @@ -705,7 +745,7 @@ describe('pause / resume integration', () => { }) it('a paused quickstart stays pinned in the hero with a Resume control', async () => { - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'q1', kind: 'quickstart', @@ -731,12 +771,12 @@ describe('pause / resume integration', () => { fireEvent.click(screen.getByRole('button', { name: /resume/i })) await waitFor(() => { - expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('q1') + expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('q1', { connectionId: null, profile: 'default' }) }) }) it('a running quickstart hero shows Pause during the download stage', async () => { - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'q1', kind: 'quickstart', @@ -765,7 +805,7 @@ describe('pause / resume integration', () => { runtime_installed: false, update_available: false }) - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'r1', kind: 'runtime-install', @@ -789,12 +829,12 @@ describe('pause / resume integration', () => { fireEvent.click(pause) await waitFor(() => { - expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('r1') + expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('r1', { connectionId: null, profile: 'default' }) }) }) it('quickstart hero suppresses the byte counter outside download phases', async () => { - $localRuntimeJobs.set([ + queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), [ { job_id: 'q1', kind: 'quickstart', diff --git a/apps/desktop/src/app/settings/local-models-settings.tsx b/apps/desktop/src/app/settings/local-models-settings.tsx index bda53c933e..75d4e51554 100644 --- a/apps/desktop/src/app/settings/local-models-settings.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.tsx @@ -1,5 +1,5 @@ -import { useStore } from '@nanostores/react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' +import { type ReactElement, useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { NEW_CHAT_ROUTE } from '@/app/routes' @@ -11,9 +11,6 @@ import { downloadBrowsedModel, downloadLocalModel, ejectLocalModel, - getLocalCatalog, - getLocalHardware, - getLocalModelsStatus, type HFFileGroup, type HFSearchHit, installLocalRuntime, @@ -42,13 +39,23 @@ import { } from '@/lib/icons' import { cn } from '@/lib/utils' import { - $localRuntimeJobs, + isCurrentLocalModelsOwner, + localModelsCatalogOptions, + localModelsHardwareOptions, + localModelsKey, + localModelsNotificationTitle, + type LocalModelsOwner, + localModelsRequestScope, + refreshLocalModels, runningDownloadFor, runningRuntimeInstall, + useLocalModelsOwner, + useLocalModelsStatus, + useLocalRuntimeJobs, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import { notify, notifyError } from '@/store/notifications' -import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' +import type { LocalCatalogModel, LocalRuntimeJob } from '@/types/hermes' import { gbLabel, @@ -80,105 +87,57 @@ function isActiveStatus(status: LocalRuntimeJob['status']): boolean { return status === 'paused' || status === 'running' } -export function LocalModelsSettings() { +export function LocalModelsSettings(): ReactElement { + const owner: LocalModelsOwner = useLocalModelsOwner() + + return +} + +function ScopedLocalModelsSettings({ owner }: { owner: LocalModelsOwner }): ReactElement { const { t } = useI18n() const copy = t.settings.localModels - const [status, setStatus] = useState(null) - const [hardware, setHardware] = useState(null) - const [catalog, setCatalog] = useState(null) + const client: QueryClient = useQueryClient() + const { data: status } = useLocalModelsStatus(owner) + const { data: hardware } = useQuery(localModelsHardwareOptions(owner)) + const { data: catalog } = useQuery(localModelsCatalogOptions(owner)) const [deleting, setDeleting] = useState(null) - const [serverBusy, setServerBusy] = useState(false) + const [serverBusy, setServerBusy] = useState(false) // Quickstart escape hatch: true once the user asks for the full pane // (model list, HF browser) instead of the one-button setup card. - const [configure, setConfigure] = useState(false) - // Jobs live in the app-level store (they must survive this pane - // unmounting); the pane just renders the slice it cares about. - const jobs = useStore($localRuntimeJobs) + const [configure, setConfigure] = useState(false) - const refresh = useCallback(() => { - void getLocalModelsStatus() - .then(setStatus) - .catch(() => setStatus(null)) - void getLocalCatalog() - .then(data => setCatalog(data.models)) - .catch(() => setCatalog([])) - }, []) + const jobs: readonly LocalRuntimeJob[] = useLocalRuntimeJobs( + owner, + (value: readonly LocalRuntimeJob[]): readonly LocalRuntimeJob[] => value + ) - // Snappy first paint: status + catalog immediately; hardware (may shell out - // to nvidia-smi) backfills and pops in-place. The job watcher also kicks - // here so reopening the pane rediscovers work started before. - useEffect(() => { - refresh() - watchLocalRuntimeJobs() - void getLocalHardware() - .then(setHardware) - .catch(() => setHardware(null)) - }, [refresh]) + const refresh = useCallback((): void => refreshLocalModels(owner, client), [owner, client]) - // The pane is LIVE while visible: residency changes without user action - // (boot warm finishing, idle sweep unloading, another surface ejecting), - // and a stale snapshot here reads as a broken feature — 'VRAM full but - // the pane says Not in memory'. The status route is built cheap for - // polling; setTimeout chain, never overlapping. - useEffect(() => { - let cancelled = false - let timer: number | undefined - - const tick = async () => { - try { - const next = await getLocalModelsStatus() - - if (!cancelled) { - setStatus(next) - } - } catch { - // Backend briefly unreachable — keep the last snapshot. - } - - if (!cancelled) { - timer = window.setTimeout(() => void tick(), 4_000) - } - } - - timer = window.setTimeout(() => void tick(), 4_000) - - return () => { - cancelled = true - - if (timer !== undefined) { - window.clearTimeout(timer) - } - } - }, []) - - // A job finishing (download done, install done) changes what status/catalog - // should show — refresh whenever the running set shrinks. - const runningCount = jobs.filter(j => j.status === 'running').length - useEffect(() => { - refresh() - }, [refresh, runningCount]) - - async function handleInstallRuntime() { + async function handleInstallRuntime(): Promise { try { - await installLocalRuntime() - watchLocalRuntimeJobs() + await installLocalRuntime(undefined, localModelsRequestScope(owner)) + watchLocalRuntimeJobs(owner, client) } catch (err) { - notifyError(err, copy.installFailed) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.installFailed) + } } } - async function handleQuickstart() { + async function handleQuickstart(): Promise { try { - await quickstartLocalModels() - watchLocalRuntimeJobs() + await quickstartLocalModels(undefined, localModelsRequestScope(owner)) + watchLocalRuntimeJobs(owner, client) } catch (err) { - notifyError(err, copy.quickstartFailed) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.quickstartFailed) + } } } - async function handleDownload(model: LocalCatalogModel) { + async function handleDownload(model: LocalCatalogModel): Promise { try { - const res = await downloadLocalModel(model.id) + const res = await downloadLocalModel(model.id, localModelsRequestScope(owner)) if (res.already_downloaded || !res.job_id) { refresh() @@ -186,55 +145,63 @@ export function LocalModelsSettings() { return } - watchLocalRuntimeJobs() + watchLocalRuntimeJobs(owner, client) } catch (err) { - notifyError(err, copy.downloadFailed(model.display_name)) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.downloadFailed(model.display_name)) + } } } - async function handleActivate(target: null | string, displayName: string) { + async function handleActivate(target: null | string, displayName: string): Promise { if (!target) { return } try { - await activateLocalModel(target) - watchLocalRuntimeJobs() + await activateLocalModel(target, localModelsRequestScope(owner)) + watchLocalRuntimeJobs(owner, client) } catch (err) { - notifyError(err, copy.activateFailed(displayName)) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.activateFailed(displayName)) + } } } - async function handleEject(modelId: string) { + async function handleEject(modelId: string): Promise { try { - await ejectLocalModel(modelId) - notify({ durationMs: 3_000, kind: 'success', message: copy.ejected, title: copy.title }) + await ejectLocalModel(modelId, localModelsRequestScope(owner)) + notify({ durationMs: 3_000, kind: 'success', message: copy.ejected, title: localModelsNotificationTitle(owner) }) refresh() } catch (err) { - notifyError(err, copy.ejectFailed) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.ejectFailed) + } } } - async function handleServer(action: 'start' | 'stop') { + async function handleServer(action: 'start' | 'stop'): Promise { setServerBusy(true) try { - await setLocalServer(action) + await setLocalServer(action, localModelsRequestScope(owner)) notify({ durationMs: 3_500, kind: 'success', message: action === 'stop' ? copy.serverStopped : copy.serverStarted, - title: copy.title + title: localModelsNotificationTitle(owner) }) refresh() } catch (err) { - notifyError(err, action === 'stop' ? copy.serverStopFailed : copy.serverStartFailed) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, action === 'stop' ? copy.serverStopFailed : copy.serverStartFailed) + } } finally { setServerBusy(false) } } - async function handleDelete(target: string, rowId: string) { + async function handleDelete(target: string, rowId: string): Promise { if (!window.confirm(copy.deleteConfirm(target))) { return } @@ -242,11 +209,18 @@ export function LocalModelsSettings() { setDeleting(rowId) try { - await deleteLocalModel(target) - notify({ durationMs: 2_500, kind: 'success', message: copy.deleted(target), title: copy.title }) + await deleteLocalModel(target, localModelsRequestScope(owner)) + notify({ + durationMs: 2_500, + kind: 'success', + message: copy.deleted(target), + title: localModelsNotificationTitle(owner) + }) refresh() } catch (err) { - notifyError(err, copy.deleteFailed) + if (isCurrentLocalModelsOwner(owner)) { + notifyError(err, copy.deleteFailed) + } } finally { setDeleting(null) } @@ -282,7 +256,7 @@ export function LocalModelsSettings() { } }, [jobs, navigate]) - if (!status || catalog === null) { + if (!status || !catalog) { return } @@ -362,7 +336,7 @@ export function LocalModelsSettings() { when controls exist (engine legs, server start report false); the paused state always offers Resume. */}
- +
{/* Stage rail: engine -> model -> finish. */} @@ -473,7 +447,7 @@ export function LocalModelsSettings() { /> ) : rJob ? ( } + action={} below={} description={rJob.detail || copy.installing} title={ @@ -515,7 +489,7 @@ export function LocalModelsSettings() { {rJob && status.runtime_installed && ( } + action={} below={} description={rJob.detail || copy.updating} title={ @@ -682,7 +656,7 @@ export function LocalModelsSettings() { ) : dJob ? ( - + ) : (