153 lines
6.3 KiB
Python
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()
|