Files
hermes-agent/scripts/termux/validate_installed.py
ethernet d81c0a3fcc fix(termux): validate updater refusal by artifact identity
Commit builds have no update channel, even when installed through dpkg. Read the installed stamp before checking the refusal message and require exit code 2 for both artifact kinds.

Verified real CLI refusal paths, negative controls, and related tests: 15 passed. Ruff and type checks passed. Full deb validation was not rerun.
2026-09-12 11:07:42 -04:00

152 lines
6.3 KiB
Python

"""Exercise a freshly installed Termux bundle with networking disabled."""
from __future__ import annotations
import fcntl
import json
import os
from pathlib import Path
import pty
import re
import select
import struct
import subprocess
import termios
import tempfile
import time
def run(argv: list[str], env: dict[str, str], cwd: Path) -> subprocess.CompletedProcess:
result = subprocess.run(argv, env=env, cwd=cwd, capture_output=True, text=True, timeout=90)
print("+", " ".join(argv), flush=True)
print(result.stdout, result.stderr, flush=True)
result.check_returncode()
return result
def stop_child_tree(child: subprocess.Popen) -> None:
import psutil
from agent.deadline import kill_process_tree
if child.poll() is not None:
return
descendants = psutil.Process(child.pid).children(recursive=True)
kill_process_tree(child.pid)
child.wait(timeout=10)
deadline = time.monotonic() + 10
while descendants and time.monotonic() < deadline:
descendants = [p for p in descendants if p.is_running() and p.status() != psutil.STATUS_ZOMBIE]
if descendants:
time.sleep(0.05)
if descendants:
raise RuntimeError(f"TUI children still alive: {[p.pid for p in descendants]}")
def tui_smoke(launcher: Path, env: dict[str, str], cwd: Path) -> None:
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
child = subprocess.Popen(
[str(launcher), "--tui"], stdin=slave, stdout=slave, stderr=slave,
cwd=cwd, env={**env, "TERM": "xterm-256color"}, start_new_session=True,
)
os.close(slave)
captured = bytearray()
ready = False
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
if select.select([master], [], [], 0.5)[0]:
try:
chunk = os.read(master, 65536)
except OSError:
break
if not chunk:
break
captured.extend(chunk)
plain = re.sub(rb"\x1b\[[0-?]*[ -/]*[@-~]", b"", bytes(captured))
if b"Setup Required" in plain and b"/model" in plain:
ready = True
break
if child.poll() is not None:
break
if not ready:
raise RuntimeError("TUI never reached its real setup screen:\n" + captured.decode(errors="replace"))
print("TUI_SETUP_SCREEN_OK", flush=True)
finally:
try:
stop_child_tree(child)
finally:
os.close(master)
def validate_update_refusal(project_root: Path, result: subprocess.CompletedProcess) -> None:
from hermes_cli.update_contract import COMMIT_BUILD_UPDATE_MESSAGE, is_commit_build
# Commit artifacts have no update channel, even when installed through dpkg.
commit_build = is_commit_build(project_root)
expected = COMMIT_BUILD_UPDATE_MESSAGE if commit_build else "pkg upgrade hermes-agent"
if result.returncode != 2 or expected not in result.stdout + result.stderr:
raise RuntimeError(f"wrong updater refusal ({result.returncode}): {result.stdout}\n{result.stderr}")
print("COMMIT_BUILD_UPDATE_REFUSAL_OK" if commit_build else "APT_UPDATE_REFUSAL_OK", flush=True)
def main() -> None:
prefix = Path(os.environ["PREFIX"])
root = prefix / "lib/hermes-agent"
with tempfile.TemporaryDirectory(prefix="hermes-install-proof-", dir=prefix / "tmp") as tmp:
home = Path(tmp)
env = {
"PREFIX": str(prefix), "HOME": str(home), "HERMES_HOME": str(home / "state"),
"PATH": str(prefix / "bin"), "TERM": "xterm-256color", "LANG": "C.UTF-8",
"LD_LIBRARY_PATH": ":".join((
str(root / "tools/python" / prefix.relative_to("/") / "lib"),
str(root / "tools/node" / prefix.relative_to("/") / "lib"),
str(root / "tools/ffmpeg" / prefix.relative_to("/") / "lib"),
str(root / "runtime-libs/lib"), str(prefix / "lib"),
)),
"HERMES_RUNTIME_DIR": str(root / "tools"),
"PYTHONPATH": str(root / "app"),
"PYTHONPYCACHEPREFIX": str(home / "pycache"),
}
launcher = prefix / "bin/hermes"
run([str(launcher), "--version"], env, home)
run([str(launcher), "chat", "--help"], env, home)
run([str(prefix / "bin/hermes-acp"), "--check"], env, home)
python = root / "venv/bin/python"
run([
str(python), "-c",
"import ctypes, ssl, sqlite3, bz2, lzma, zlib, hashlib, readline; "
"import cli, run_agent, tui_gateway.server; "
"from hermes_cli.config import detect_install_method; "
"assert detect_install_method() == 'apt'; "
"print('CLI_AND_STDLIB_IMPORTS_OK')",
], env, home)
natives = json.loads((root / "native-wheels.json").read_text(encoding="utf-8"))
run([
str(python), str(root / "app/scripts/termux/build_wheels.py"),
"--import-modules", *natives,
], env, home)
node = root / "tools/node" / prefix.relative_to("/") / "bin/node"
run([str(node), "--version"], env, home)
run([str(node), str(root / "tools/npm/lib/node_modules/npm/bin/npm-cli.js"), "--version"], env, home)
run([str(root / "tools/ripgrep/rg"), "--version"], env, home)
ffmpeg = root / "tools/ffmpeg" / prefix.relative_to("/") / "bin/ffmpeg"
output = home / "silence.wav"
run([str(ffmpeg), "-hide_banner", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", "-t", "0.1", str(output)], env, home)
import wave
with wave.open(str(output), "rb") as audio:
assert audio.getnframes() > 0 and audio.getframerate() == 16000
print("FFMPEG_MEDIA_CONVERSION_OK", flush=True)
run([
str(python), "-c",
"import pm; issues = pm.check(); "
"assert not issues, issues; print('PM_RUNTIME_TOOLS_OK')",
], env, home)
result = subprocess.run([str(launcher), "update"], env=env, cwd=home, capture_output=True, text=True, timeout=60)
validate_update_refusal(root / "app", result)
tui_smoke(launcher, env, home)
print("INSTALLED_BUNDLE_VALIDATION_OK", flush=True)
if __name__ == "__main__":
main()