Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".
- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
installed copy or None. Every caller already pm.ensure()s on None, so a
missing runtime is now provisioned instead of silently borrowing the
user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
another npm.
- source_build.source_product_current: run the freshness reader only with
PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
artifact; delete the "uv on PATH if new enough" developer shortcut.
103 lines
5.0 KiB
Python
103 lines
5.0 KiB
Python
"""Receipt validation uses packaged output, not just a source stamp."""
|
|
import json
|
|
import struct
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import desktop_update_verify as verify
|
|
from tests.hermes_cli.test_source_build import copy_freshness_scripts, stamp_product, use_host_node_as_pm_node
|
|
|
|
|
|
@pytest.fixture
|
|
def bundle(tmp_path, monkeypatch):
|
|
desktop = tmp_path / 'apps/desktop'
|
|
resources = desktop / 'release/fixture/resources'
|
|
dist = resources / 'app.asar.unpacked/dist'
|
|
(dist / 'assets').mkdir(parents=True)
|
|
(dist / 'index.html').write_text('<script type="module" src="./assets/index.js"></script>', encoding='utf-8')
|
|
(dist / 'assets/index.js').write_text('export {};', encoding='utf-8')
|
|
entry = b'import "electron";'
|
|
(dist / 'electron-main.mjs').write_bytes(entry)
|
|
package = json.dumps({'main': 'dist/electron-main.mjs'}).encode()
|
|
header = json.dumps({'files': {'package.json': {'size': len(package), 'offset': '0'}, 'dist': {'files': {'electron-main.mjs': {'size': len(entry), 'unpacked': True}}}}}).encode()
|
|
padded = header + b'\0' * (-len(header) % 4)
|
|
archive = resources / 'app.asar'
|
|
archive.write_bytes(struct.pack('<4I', 4, 8 + len(padded), 4 + len(padded), len(header)) + padded + package)
|
|
(tmp_path / '.gitignore').write_text('apps/desktop/release/\n', encoding='utf-8')
|
|
monkeypatch.setattr(verify, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe')
|
|
monkeypatch.setattr(verify, '_desktop_exe_integrity_error', lambda _: None)
|
|
# Host-independent artifact contract; executable lookup itself is covered natively.
|
|
from hermes_cli import main_desktop
|
|
monkeypatch.setattr(main_desktop, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe')
|
|
copy_freshness_scripts(tmp_path)
|
|
stamp_product(tmp_path, "desktop", dist)
|
|
use_host_node_as_pm_node(monkeypatch)
|
|
return tmp_path, archive, dist
|
|
|
|
|
|
def test_readable_packaged_entry_passes(bundle):
|
|
root, _, _ = bundle
|
|
verify.verify_windows_desktop_update(root)
|
|
|
|
|
|
@pytest.mark.parametrize('damage', ['archive', 'truncated', 'entry', 'empty-index', 'unreadable-index', 'no-module'])
|
|
def test_current_stamp_does_not_hide_damaged_output(bundle, damage):
|
|
root, archive, dist = bundle
|
|
if damage == 'archive':
|
|
archive.write_bytes(b'not an asar')
|
|
elif damage == 'truncated':
|
|
archive.write_bytes(archive.read_bytes()[:-4])
|
|
elif damage == 'entry':
|
|
(dist / 'electron-main.mjs').write_bytes(b'')
|
|
else:
|
|
(dist / 'index.html').write_bytes({'empty-index': b'', 'unreadable-index': b'\xff', 'no-module': b'<html></html>'}[damage])
|
|
with pytest.raises((RuntimeError, OSError, ValueError)):
|
|
verify.verify_windows_desktop_update(root)
|
|
|
|
|
|
def test_default_root_is_the_imported_checkout_not_cwd(tmp_path, monkeypatch):
|
|
# The Windows hand-off is spawned from HERMES_HOME; the receipt must describe the checkout anyway.
|
|
monkeypatch.chdir(tmp_path)
|
|
seen = {}
|
|
monkeypatch.setattr(verify, '_desktop_packaged_executable', lambda desktop: seen.setdefault('desktop', desktop) and None)
|
|
with pytest.raises(RuntimeError, match='executable is missing'):
|
|
verify.verify_windows_desktop_update()
|
|
assert seen['desktop'] == verify.checkout_root() / 'apps' / 'desktop'
|
|
assert (verify.checkout_root() / 'hermes_cli' / 'desktop_update_verify.py').is_file()
|
|
assert verify.checkout_root() != tmp_path
|
|
|
|
|
|
def _app_only_under(root):
|
|
"""A packaged-app lookup that finds an app under *root* and nowhere else."""
|
|
from pathlib import Path
|
|
|
|
desktop = (root / 'apps' / 'desktop').resolve()
|
|
|
|
def lookup(candidate):
|
|
return desktop / 'release/fixture/Hermes.exe' if Path(candidate).resolve() == desktop else None
|
|
|
|
return lookup
|
|
|
|
|
|
def test_caller_root_without_a_packaged_app_falls_back_to_the_checkout(bundle, tmp_path, monkeypatch):
|
|
# A hand-off script read before the pull still passes HERMES_HOME, which never
|
|
# holds a packaged app. The healthy checkout it just wrote must be verified instead.
|
|
checkout, _, _ = bundle
|
|
monkeypatch.setattr(verify, '_desktop_packaged_executable', _app_only_under(checkout))
|
|
monkeypatch.setattr(verify, 'checkout_root', lambda: checkout)
|
|
verify.verify_windows_desktop_update(tmp_path / 'hermes_home')
|
|
|
|
|
|
def test_caller_root_that_has_a_packaged_app_is_verified_as_given(bundle, tmp_path, monkeypatch):
|
|
# Fail-closed: the fallback is for a root with nothing to read, never a way to
|
|
# launder a damaged build past the receipt by switching to a healthy tree.
|
|
checkout, _, _ = bundle
|
|
supplied = tmp_path / 'supplied'
|
|
resources = supplied / 'apps/desktop/release/fixture/resources'
|
|
resources.mkdir(parents=True)
|
|
(resources / 'app.asar').write_bytes(b'not an asar')
|
|
monkeypatch.setattr(verify, '_desktop_packaged_executable', _app_only_under(supplied))
|
|
monkeypatch.setattr(verify, 'checkout_root', lambda: checkout)
|
|
with pytest.raises(RuntimeError, match='archive or main entry is invalid'):
|
|
verify.verify_windows_desktop_update(supplied)
|