Files
hermes-agent/scripts/termux/validate_installed.py
2026-09-23 07:47:27 -04:00

153 lines
6.3 KiB
Python

"""Exercise a freshly installed Termux bundle with networking disabled."""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import select
import struct
import subprocess
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, encoding="utf-8", 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:
import fcntl
import pty
import termios
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-sig"))
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, encoding="utf-8", timeout=60)
validate_update_refusal(root / "app", result)
tui_smoke(launcher, env, home)
print("INSTALLED_BUNDLE_VALIDATION_OK", flush=True)
if __name__ == "__main__":
main()