refactor(termux): reuse package requirements and harden launchers

Use one requirements writer for the wheelhouse and installed venv. Do not retry failed hashes or paused downloads. Skip pure-wheel decompression, preserve runtime library notices, and keep the current working directory off the launcher import path. Document the prerelease canary package without migration or downgrade guidance.
This commit is contained in:
ethernet
2026-09-06 14:34:52 -04:00
parent af8212b1f6
commit 1ce0998ac9
10 changed files with 120 additions and 128 deletions

View File

@@ -262,12 +262,15 @@ def _fetch_with_retry(store, url: str, sha256: str, scratch, progress=None, atte
still proves the bytes; a retry cannot smuggle anything past the pin.
"""
import time
from pm.downloader import DownloadPaused, HashError
last: Exception | None = None
for attempt in range(attempts):
try:
return store.fetch(url, sha256, scratch, progress=progress)
except Exception as exc: # noqa: BLE001 -- retry any fetch failure
except (HashError, DownloadPaused):
raise
except Exception as exc: # noqa: BLE001 -- transient fetch failures retain bounded retries
last = exc
if attempt + 1 < attempts:
wait = 30 * (attempt + 1)

View File

@@ -16,7 +16,6 @@ cd "$REPO_ROOT"
DIGEST="$(python3 -c 'import sys; sys.path.insert(0, "."); from pm.lock import termux_docker_digest; print(termux_docker_digest())')"
[ -n "$DIGEST" ] || { echo "termux-docker digest missing" >&2; exit 1; }
[ -n "$DIGEST" ] || { echo "termux-docker digest missing" >&2; exit 1; }
BASE="termux/termux-docker@${DIGEST}"
SHORT="${DIGEST#sha256:}"

View File

@@ -94,27 +94,14 @@ if [ -d "$PAYLOAD_ABS/venv" ]; then rm -rf "$PAYLOAD_ABS/venv"; fi
# evaluates them on bionic) and documented android build misses skipped --
# uv pip check tolerates the app importing without them (its relay exporter
# is the only casualty). Generated host-side; consumed in-container.
python3 - "$PAYLOAD_ABS/.work/resolved.txt" "$PAYLOAD_ABS/.work/resolved-reqs.txt" <<'PYREQS' \
python3 - "$HERE" "$PAYLOAD_ABS/.work/resolved.txt" "$PAYLOAD_ABS/.work/resolved-reqs.txt" <<'PYREQS' \
|| fail "deb-venv reqs generation failed"
import sys
from pathlib import Path
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
MISSES = {"nemo-relay"}
out = []
for line in src.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
name, spec, marker = parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else ""
if name in MISSES:
continue
req = f"{name}{spec.strip()}" if spec.strip() else name
if marker:
req += f" ; {marker}"
out.append(req)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(chr(10).join(out) + chr(10), encoding="utf-8")
sys.path.insert(0, sys.argv[1])
from build_wheels import write_reqs_file
write_reqs_file(Path(sys.argv[2]), Path(sys.argv[3]))
PYREQS
# The bind mount is runner-owned: the container (any uid) can only write
# into a dir the HOST pre-created with open perms (same as the wheelhouse).

View File

@@ -29,7 +29,7 @@ export HERMES_NODE="$root/tools/node/data/data/com.termux/files/usr/bin/node"
export HERMES_RUNTIME_DIR="$root/tools"
export PATH="$root/tools/npm/bin:$root/tools/node/data/data/com.termux/files/usr/bin:$root/tools/ffmpeg/data/data/com.termux/files/usr/bin:$root/tools/ripgrep:$PATH"
export PYTHONPYCACHEPREFIX="${PYTHONPYCACHEPREFIX:-${XDG_CACHE_HOME:-$HOME/.cache}/hermes-pycache}"
exec "$HERMES_PYTHON" -c __ENTRY__ "$@"
exec "$HERMES_PYTHON" -P -c __ENTRY__ "$@"
'''
@@ -38,7 +38,7 @@ def write_launchers(payload: Path, entries: dict[str, str]) -> None:
bindir.mkdir(parents=True, exist_ok=True)
for name, entry in entries.items():
module, func = entry.split(":", 1)
script = f"import sys; from {module} import {func}; sys.exit({func}())"
script = f"import sys; sys.argv[0] = {name!r}; from {module} import {func}; sys.exit({func}())"
path = bindir / name
path.write_text(_LAUNCHER.replace("__ENTRY__", shlex.quote(script)), encoding="utf-8")
path.chmod(0o755)

View File

@@ -50,6 +50,8 @@ def repair_wheel(wheel: Path, library: Path, *, repair=link_extension) -> int:
with tempfile.TemporaryDirectory(prefix="hermes-wheel-link-") as tmp:
native = Path(tmp) / "extension.so"
with zipfile.ZipFile(wheel) as archive:
if not any(info.filename.endswith(".so") for info in archive.infolist()):
return 0
members = [(info, archive.read(info)) for info in archive.infolist() if not info.is_dir()]
records = [info.filename for info, _ in members if info.filename.endswith(".dist-info/RECORD")]
if len(records) != 1:

View File

@@ -41,7 +41,7 @@ sys.path.insert(0, str(REPO_ROOT))
from pm.downloader import Download, Source # noqa: E402
from pm.package import DebPackage # noqa: E402
PREFIX_REL = "data/data/com.termux/files/usr"
PREFIX_REL = DebPackage.prefix_rel
MANIFEST_NAME = "manifest.json"
@@ -105,9 +105,7 @@ def _cache_valid(out: Path, manifest_path: Path, table: dict) -> bool:
def _ensure_extracted(work: Path, name: str, row: dict) -> Path:
"""Return the package's extract dir, downloading + unpacking the
digest-verified .deb when the extraction is absent or stale (its
extraction marker does not match the currently pinned sha256)."""
"""Extract fresh bytes from a digest-verified archive on every cache miss."""
extract = work / "extract" / name
scratch = work / "dl"
scratch.mkdir(parents=True, exist_ok=True)
@@ -173,6 +171,10 @@ def stage(payload: Path, table: dict) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(so, dest)
n += 1
notices = extract / PREFIX_REL / "share/doc"
if notices.is_dir():
target = payload / "runtime-libs/share/doc"
shutil.copytree(notices, target, dirs_exist_ok=True)
merged += n
print(f" {name} {row['version']}: {n} new .so* -> runtime-libs/lib")

View File

@@ -363,9 +363,7 @@ chmod 0777 "$OUT_ABS/wheelhouse"
# The cache verdict was computed before [d]; on a hit the resolve, probe,
# and this container phase are all skipped (same flag guards each).
if [ "$WHEELHOUSE_CACHE_OK" -eq 1 ]; then
:
else
if [ "$WHEELHOUSE_CACHE_OK" -eq 0 ]; then
C_RESOLVED="/out/.work/resolved.txt"
C_BUILD_SET="/out/.work/build_set.txt"
C_WHEELHOUSE="/out/wheelhouse"

View File

@@ -168,3 +168,16 @@ def test_stage_only_repin_same_version_rebuilds(tmp_path, sandbox, monkeypatch):
# And the rebuilt entry is again idempotent.
stable = ensure_mod.stage_only("stage-test", TARGET)
assert (stable / "bin" / "tool").read_bytes() == payload_b
@pytest.mark.parametrize("error_name", ["HashError", "DownloadPaused"])
def test_permanent_or_paused_download_is_not_retried(monkeypatch, tmp_path, error_name):
from pm import downloader
class FailingStore:
def fetch(self, *args, **kwargs):
raise getattr(downloader, error_name)("stop")
monkeypatch.setattr("time.sleep", lambda _: pytest.fail("permanent failure was retried"))
with pytest.raises(getattr(downloader, error_name)):
ensure_mod._fetch_with_retry(FailingStore(), "https://example.test/tool", "digest", tmp_path)

View File

@@ -26,6 +26,7 @@ def test_launchers_forward_arguments_and_export_payload_environment(tmp_path):
"['HERMES_NODE', 'HERMES_PYTHON', 'HERMES_RUNTIME_DIR', 'PYTHONPATH', 'PYTHONHOME', 'PYTHONPYCACHEPREFIX']}}))\n",
encoding="utf-8",
)
(tmp_path / "json.py").write_text("raise RuntimeError('cwd shadowed stdlib')\n", encoding="utf-8")
entries = {name: "capture_entry:main" for name in ("hermes", "hermes-agent", "hermes-acp")}
write_launchers(payload, entries)
bin_dir = tmp_path / "prefix/bin"

View File

@@ -1,149 +1,136 @@
---
sidebar_position: 3
title: "Android / Termux"
description: "Install Hermes Agent on Android via Termux using our signed APT package"
description: "Install Hermes Agent on Android with the signed Termux package"
---
# Hermes on Android with Termux
Hermes Agent runs on Android through [Termux](https://termux.dev/) as a native
APT package, served from our own signed package repository.
The Termux package runs Hermes on **aarch64 (arm64-v8a)** Android devices.
This package is in prerelease testing.
:::info Supported configuration
Termux on **aarch64 (arm64-v8a)** devices is the supported configuration. Other
architectures are not packaged yet.
:::
The package includes Python, Node.js, npm, uv, ripgrep, ffmpeg, and their runtime libraries.
CI builds the native Python wheels and the TUI before it creates the package.
The device does not compile dependencies or assemble a Python environment during installation.
## How the install works
## Install
The package is fully self-contained: it ships its own Python and its own Node,
and the entire dependency set is pre-validated at build time. Nothing is
compiled or downloaded on the device during install, and it does not depend on
Termux's `python` or `nodejs` packages.
Use the standard [Termux](https://termux.dev/) application.
The package requires its standard prefix, `/data/data/com.termux/files/usr`.
Other architectures and renamed Termux application packages are not supported.
The package is distributed through our signed APT repository (hosted on our
release storage). Installing means:
1. Telling `pkg` about the repository for the channel you want
(see [Channels](#channels) below).
2. Importing the repository's GPG public key so `pkg` can verify the
packages.
3. Installing the package:
1. Install the tools for repository setup:
```bash
pkg install curl gnupg
```
2. Download the public key:
```bash
mkdir -p "$PREFIX/etc/apt/keyrings"
curl -fsSL \
https://hermes-assets.nousresearch.com/releases/termux/canary/key.asc \
-o "$PREFIX/etc/apt/keyrings/hermes-agent.asc"
```
3. Verify its primary fingerprint:
```bash
gpg --show-keys --with-fingerprint "$PREFIX/etc/apt/keyrings/hermes-agent.asc"
```
The repository key fingerprint is:
```text
C572 B5FD D1A2 9CCF A9A9 12B6 840B 0848 E139 156D
```
If the fingerprint differs, stop. Do not disable signature verification.
4. Add the canary repository:
```bash
printf '%s\n' \
"deb [signed-by=$PREFIX/etc/apt/keyrings/hermes-agent.asc] https://hermes-assets.nousresearch.com/releases/termux/canary hermes-canary main" \
> "$PREFIX/etc/apt/sources.list.d/hermes-agent.list"
```
5. Install Hermes:
```bash
pkg update
pkg install hermes-agent
```
The `hermes` command is a symlink in `$PREFIX/bin`, so it is available from any
Termux shell immediately after install.
6. Configure a provider, then start the TUI:
### Installing step by step
```bash
hermes setup
hermes --tui
```
For the **canary** channel (aarch64 devices):
The `hermes`, `hermes-agent`, and `hermes-acp` commands use the packaged runtimes.
They do not require Termux's `python` or `nodejs` packages.
```bash
# 1. Import the repository signing key (published by the repo itself).
mkdir -p $PREFIX/etc/apt/keyrings
curl -fsSL \
https://hermes-assets.nousresearch.com/releases/termux/canary/key.asc \
-o $PREFIX/etc/apt/keyrings/hermes-agent.asc
## Files and updates
# 2. Add the repository.
echo "deb [signed-by=$PREFIX/etc/apt/keyrings/hermes-agent.asc] https://hermes-assets.nousresearch.com/releases/termux/canary hermes-canary main" \
> $PREFIX/etc/apt/sources.list.d/hermes-agent.list
| Contents | Location |
| --- | --- |
| Package files | `$PREFIX/lib/hermes-agent/` |
| Command symlinks | `$PREFIX/bin/hermes`, `$PREFIX/bin/hermes-agent`, `$PREFIX/bin/hermes-acp` |
| Configuration and user data | `~/.hermes/`, or the selected `HERMES_HOME` |
# 3. Install.
pkg update
pkg install hermes-agent
```
For the **stable** channel, use `stable` in place of `canary` in both URLs
(and the suite name `hermes-stable` in the `deb` line).
:::tip Verify the key by fingerprint first
If you prefer to check what you are trusting, fetch the key, inspect it with
`gpg --show-keys <keyfile>`, and compare the fingerprint against the one we
publish on the [releases page](https://github.com/NousResearch/hermes-agent/releases).
:::
## What gets installed
Everything lives in a single dpkg-owned directory, with one symlink outside
it:
| What | Where |
| --------------------------- | ------------------------------ |
| The app (Python, Node, venv) | `$PREFIX/lib/hermes-agent/` |
| The `hermes` launcher | `$PREFIX/bin/hermes` (symlink) |
| Your data | `~/.hermes/` |
`$PREFIX` is Termux's install prefix (typically `/data/data/com.termux/files/usr`).
Your data - configuration, sessions, skills, memories - lives in `~/.hermes/`
and is separate from the package.
## Channels
Two channels are published, mirroring the desktop release channels:
- **stable** - tagged releases.
- **canary** - built from the latest development state.
Point the repository `sources.list` entry at the channel you want
(`hermes-stable` or `hermes-canary` distribution). Version strings for
nightlies sort below stable, so switching back to stable always upgrades.
## Updating
Updates come through the package manager:
Update through APT:
```bash
pkg upgrade hermes-agent
```
`hermes update` on a Termux install refuses to self-update and prints exactly
this command instead - the package manager owns the install.
`hermes update` refuses to modify an APT-owned installation.
It prints the package-manager command instead.
Canary versions contain `~canary.<timestamp>` and sort before the corresponding stable version.
## Running the gateway
## Gateway
Termux has no service manager, so the messaging gateway runs as a foreground
process in a Termux session:
Termux has no system service manager. Run the gateway in a Termux session:
```bash
hermes gateway run
```
Or in the background with `nohup`:
For a background process:
```bash
hermes gateway stop
nohup hermes gateway run >> ~/.hermes/logs/gateway.log 2>&1 &
mkdir -p "${HERMES_HOME:-$HOME/.hermes}/logs"
nohup hermes gateway run >> "${HERMES_HOME:-$HOME/.hermes}/logs/gateway.log" 2>&1 &
```
:::warning Android phantom process killer
Android may suspend or kill background processes, including Termux jobs. To
keep a background gateway alive, exclude Termux from battery optimization
(Android Settings -> Apps -> Termux -> Battery -> Unrestricted) and keep the
Termux session alive (a wake lock via `termux-wake-lock` helps). Treat
background gateway persistence on a phone as best-effort.
:::warning Android process limits
Android can suspend or terminate background Termux processes.
Battery optimization exemptions and `termux-wake-lock` can help, but do not guarantee persistent operation.
:::
## Uninstalling
## Limits
The package does not include the `nemo-relay` exporter because its build does not support this target.
Optional integrations can require additional dependencies or services.
A prebuilt core runtime does not guarantee that every third-party plugin supports Android.
## Uninstall
```bash
pkg uninstall hermes-agent
```
This removes the package and the `$PREFIX/bin/hermes` symlink. Your data in
`~/.hermes/` is left untouched.
APT removes the package and its command symlinks. It preserves your configuration, sessions, skills, and memories.
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `Unable to locate package hermes-agent` | The repository `sources.list` entry is missing or the channel name is wrong - re-check it, then run `pkg update`. |
| Signature / GPG errors during `pkg update` | The repository public key isn't imported (or is stale) - re-fetch it from the channel's `key.asc` URL and re-run `pkg update`. |
| `hermes: command not found` | Reinstall the package, or check that `$PREFIX/bin` is on your `PATH`. |
| Gateway dies when the screen turns off | See the [phantom process killer note](#running-the-gateway) - battery-optimization exemption plus `termux-wake-lock`. |
- **Package not found:** verify the repository entry, then run `pkg update`.
- **Signature error:** verify the public key fingerprint. Do not use an unsigned repository or bypass the error.
- **Missing command:** verify that `$PREFIX/bin` is on `PATH`, or reinstall the package.
- **Missing library or TUI bundle:** report `hermes --version` and the complete error. The core package must not require a local rebuild.
- **Gateway stops with the screen off:** review Android's battery and background-process limits.
For general diagnostics, run `hermes doctor`.