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:
@@ -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)
|
||||
|
||||
@@ -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:}"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user