Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Fable 5 357723392d ci(plugin): run native-layout import guard + document plugin-pkg parent assumption
plugin/tests/test_native_layout_imports.py (the AST guard against absolute
plugin.* imports + the native-loader smoke test) was never in ci-plugin.yml's
pytest file list, so a reintroduced absolute import would pass CI. Add it to
the invocation and switch the dependency step to `pip install -e .` so the
smoke test's full relay import chain (requests/httpx/segno/websocket-client)
resolves under CI's clean subprocess.

Also document, on _plugin_module()'s dotted-__package__ branch, the assumption
that our parent package is the plugin package (true for both real layouts:
plugin.dashboard and hermes_plugins.hermes_relay.dashboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:11:54 -04:00
Bailey DixonandClaude Fable 5 8e96a019a2 fix(installer): point generated systemd unit + shims at the detected venv
install.sh already autodetects the venv layout (classic venv, uv-managed
.venv, Docker), but the systemd unit template and the pair/status/relay/
tailscale shims still hardcoded the classic ~/.hermes/hermes-agent/venv
interpreter. On a uv-managed host the install reported success while
ExecStart pointed at a nonexistent python -> 203/EXEC, dead relay, broken
shims.

Rewrite the unit's ExecStart/PATH/VIRTUAL_ENV to the detected venv dir at
install time (sed over the committed classic-default template), and make
every generated shim try the detected interpreter first. HERMES_VENV_PY
still wins; the classic + uv layouts remain runtime fallbacks so a relocated
venv self-heals without a reinstall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:11:53 -04:00
Bailey DixonandClaude Fable 5 ba18fe95c0 docs(plugin): note the official Docker image install path
The immutable nousresearch/hermes-agent image (/opt/hermes/.venv) only
supports the native 'hermes plugins install' path; install.sh's
editable/systemd path is not applicable there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:49:28 -04:00
Bailey DixonandClaude Fable 5 70015beb81 test(plugin): native-layout import guard + loader smoke test
Two layers so #165's failure class can't regress silently:
- AST guard over every runtime module under plugin/ asserting no
  absolute plugin.* imports remain (tests exempt).
- Native-layout smoke: copies the plugin tree to a tempdir under a
  different package name and, in a subprocess with top-level 'plugin'
  imports blocked, loads it exactly like upstream's
  PluginManager._load_directory_module, then imports the relay server
  chain, tailscale CLI, pair, doctor, and enhancements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:49:28 -04:00
Bailey DixonandClaude Fable 5 5a44d4519a fix(installer): autodetect venv layouts and steer Docker installs to the native path
install.sh hardcoded ~/.hermes/hermes-agent/venv/bin/python and died
mid-run on the official Docker image (#165). Now auto-detects, in order:
the classic venv, a uv-managed .venv, and /opt/hermes/.venv (official
Docker image). The Docker layout is immutable — no clone, no user
systemd, site-packages reset on pull — so the installer refuses it
early with a steer to 'hermes plugins install
Codename-11/hermes-relay/plugin' and exits nonzero. An explicit
HERMES_VENV_PY still forces through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:49:28 -04:00
Bailey DixonandClaude Fable 5 fcbf5666e4 feat(plugin): doctor check that imports the relay server chain
'hermes relay doctor' previously passed on installs where 'hermes relay
start' crashed at import time (#165) because no check ever imported the
relay code. Add a relay-import-chain check that imports
<plugin pkg>.relay.server under the CURRENT package layout and reports
an actionable error (update the plugin / reinstall) when it fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:49:27 -04:00
Bailey DixonandClaude Fable 5 865c39bd86 fix(plugin): package-relative imports so the native plugin loader works (#165)
hermes-agent's native installer imports the plugin directory as
hermes_plugins.hermes_relay — no top-level 'plugin' package exists there,
so every absolute 'from plugin.X' import crashed 'hermes relay start'
with ModuleNotFoundError: No module named 'plugin'.

- Convert all runtime absolute plugin.* imports to package-relative form
  (relay voice/realtime chain, tailscale CLI, pair, enhancements, tools).
- android_tool's direct-script fallback now imports the sibling module
  bare instead of via 'plugin.tools.'.
- dashboard/plugin_api.py is exec'd standalone by the dashboard web
  server (spec_from_file_location, no parent package), so relative
  imports can't work there: add a _plugin_module() bootstrap that
  imports through the real parent package when one exists, and
  otherwise synthesizes it (bare ModuleType with __path__ at the plugin
  dir under a stable sys.modules alias) without exec'ing
  plugin/__init__.py side effects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:49:07 -04:00
21 changed files with 446 additions and 72 deletions
+8 -2
View File
@@ -111,7 +111,12 @@ jobs:
- name: Install dependencies
run: |
pip install -r relay_server/requirements.txt
# Editable install pulls the full runtime dependency set from
# pyproject.toml (requests, aiohttp, segno, httpx, websocket-client,
# pyyaml). test_native_layout_imports imports the whole relay module
# chain in a clean subprocess, so the minimal relay_server/requirements
# set is not enough on its own.
pip install -e .
pip install pytest responses
- name: Run focused Plugin tests
@@ -119,4 +124,5 @@ jobs:
python -m pytest \
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py
+97 -15
View File
@@ -90,8 +90,13 @@
# HERMES_RELAY_HOME Target directory (default: ~/.hermes/hermes-relay)
# HERMES_RELAY_BRANCH Git branch to install (default: main). Superseded
# by --branch when both are provided.
# HERMES_VENV_PY Path to hermes-agent venv python
# (default: ~/.hermes/hermes-agent/venv/bin/python)
# HERMES_VENV_PY Path to hermes-agent venv python. When unset the
# installer auto-detects, in order:
# ~/.hermes/hermes-agent/venv/bin/python (classic)
# ~/.hermes/hermes-agent/.venv/bin/python (uv-managed)
# /opt/hermes/.venv/bin/python (official Docker image)
# The Docker layout is immutable — the installer
# refuses it and steers to the native plugin install.
# HERMES_HOME Hermes config home (default: ~/.hermes)
# HERMES_RELAY_NO_SYSTEMD Skip step [6/6] even if systemd is available
# (set to any non-empty value)
@@ -173,7 +178,15 @@ BRANCH="${_BRANCH_FLAG:-${HERMES_RELAY_BRANCH:-main}}"
DASHBOARD_PLUGIN="${_DASHBOARD_PLUGIN_FLAG:-${HERMES_RELAY_DASHBOARD_PLUGIN:-yes}}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
RELAY_HOME="${HERMES_RELAY_HOME:-$HERMES_HOME/hermes-relay}"
VENV_PY="${HERMES_VENV_PY:-$HERMES_HOME/hermes-agent/venv/bin/python}"
# Resolved below (after helpers) — empty means "auto-detect".
VENV_PY="${HERMES_VENV_PY:-}"
# The official Docker image (nousresearch/hermes-agent) ships hermes-agent
# pre-installed here. It is an immutable container layout: no git clone at
# $HERMES_HOME/hermes-agent, no user systemd, and site-packages that vanish
# on the next `docker pull`. This script's editable-install path cannot work
# there, so detection of this interpreter triggers a steer to the native
# `hermes plugins install` path instead of dying mid-run.
DOCKER_VENV_PY="/opt/hermes/.venv/bin/python"
PLUGIN_LINK="$HERMES_HOME/plugins/hermes-relay"
SKILLS_DIR_IN_REPO="$RELAY_HOME/skills"
HERMES_CONFIG="$HERMES_HOME/config.yaml"
@@ -258,6 +271,24 @@ require() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"
}
# ── Venv autodetection ─────────────────────────────────────────────────────
# hermes-agent installs land in several layouts. HERMES_VENV_PY always wins
# when set; otherwise probe, in order: classic pip/git venv, uv-managed
# .venv, official Docker image.
_VENV_AUTODETECTED=""
if [ -z "$VENV_PY" ]; then
for _cand in \
"$HERMES_HOME/hermes-agent/venv/bin/python" \
"$HERMES_HOME/hermes-agent/.venv/bin/python" \
"$DOCKER_VENV_PY"; do
if [ -x "$_cand" ]; then
VENV_PY="$_cand"
_VENV_AUTODETECTED=1
break
fi
done
fi
# ── Banner ─────────────────────────────────────────────────────────────────
banner() {
printf "\n"
@@ -268,7 +299,7 @@ banner() {
printf "\n"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Repo:" "$REPO_URL ${C_DIM}($BRANCH)${C_RESET}"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Target:" "$RELAY_HOME"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Venv:" "$VENV_PY"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Venv:" "${VENV_PY:-(not found — see below)}"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Hermes:" "$HERMES_CONFIG"
}
@@ -277,12 +308,26 @@ banner
require git
require python3
# Immutable/container layout — refuse early with a usable next step instead
# of failing partway through the editable install / systemd setup.
if [ -n "$_VENV_AUTODETECTED" ] && [ "$VENV_PY" = "$DOCKER_VENV_PY" ]; then
printf "\n"
warn "Official Hermes Docker image detected ($DOCKER_VENV_PY)."
info "This installer's editable install + systemd + shell shims do not"
info "apply inside the immutable container image."
info "Install the plugin the native way instead:"
printf "\n ${C_BOLD}hermes plugins install Codename-11/hermes-relay/plugin${C_RESET}\n\n"
info "then enable it when prompted and restart the container."
info "(To force this script anyway, set HERMES_VENV_PY explicitly.)"
exit 1
fi
if [ ! -d "$HERMES_HOME/hermes-agent" ]; then
die "hermes-agent not found at $HERMES_HOME/hermes-agent — install Hermes first"
fi
if [ ! -x "$VENV_PY" ]; then
die "hermes-agent venv Python not found at $VENV_PY — reinstall hermes-agent or set HERMES_VENV_PY"
if [ -z "$VENV_PY" ] || [ ! -x "$VENV_PY" ]; then
die "hermes-agent venv Python not found (tried $HERMES_HOME/hermes-agent/venv, $HERMES_HOME/hermes-agent/.venv, and /opt/hermes/.venv) — reinstall hermes-agent or set HERMES_VENV_PY"
fi
# ── 1/6 Clone or update the repo ──────────────────────────────────────────
@@ -529,9 +574,18 @@ cat > "$SHIM_PATH" <<SHIM
#
# Also available: /hermes-relay-pair slash command in any Hermes chat session.
#
# Override the venv python path with \$HERMES_VENV_PY if needed.
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-pair: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-pair: set HERMES_VENV_PY or reinstall hermes-agent" >&2
@@ -549,9 +603,18 @@ cat > "$STATUS_SHIM_PATH" <<SHIM
#
# Also available: /hermes-relay-status slash command in any Hermes chat session.
#
# Override the venv python path with \$HERMES_VENV_PY if needed.
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-status: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-status: set HERMES_VENV_PY or reinstall hermes-agent" >&2
@@ -577,7 +640,7 @@ set -eu
HERMES_RELAY_HOME="\${HERMES_RELAY_HOME:-\$HOME/.hermes/hermes-relay}"
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
@@ -655,9 +718,18 @@ cat > "$TS_SHIM_PATH" <<SHIM
# hermes-relay-tailscale enable [--port N] [--api-port N] [--relay-only]
# hermes-relay-tailscale disable [--port N] [--api-port N] [--relay-only]
#
# Override the venv python path with \$HERMES_VENV_PY if needed.
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
set -eu
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-relay-tailscale: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-relay-tailscale: set HERMES_VENV_PY or reinstall hermes-agent" >&2
@@ -691,8 +763,18 @@ elif [ ! -f "$SERVICE_SRC" ]; then
info " Service template not found at $SERVICE_SRC — skipping"
else
mkdir -p "$SYSTEMD_USER_DIR"
cp "$SERVICE_SRC" "$SERVICE_DST"
ok "Wrote $SERVICE_DST"
# The committed template hardcodes the classic %h/.hermes/hermes-agent/venv
# layout. Rewrite that prefix to the venv we actually detected (uv-managed
# .venv, a HERMES_VENV_PY override, etc.) so ExecStart / PATH / VIRTUAL_ENV
# point at a real interpreter instead of dying with 203/EXEC on non-classic
# hosts. $VENV_PY is ".../<venv>/bin/python"; strip "/bin/python" for the dir.
# A `|` delimiter avoids clashing with the `/` path separators. We assume the
# venv path has no sed metacharacters (& | \) — true for the standard
# ~/.hermes/hermes-agent/{venv,.venv} layouts and any sane HERMES_VENV_PY.
_venv_bin_dir="${VENV_PY%/*}" # .../<venv>/bin
_venv_root_dir="${_venv_bin_dir%/*}" # .../<venv>
sed "s|%h/.hermes/hermes-agent/venv|$_venv_root_dir|g" "$SERVICE_SRC" > "$SERVICE_DST"
ok "Wrote $SERVICE_DST (venv → $_venv_root_dir)"
systemctl --user daemon-reload >/dev/null 2>&1 || true
+7
View File
@@ -36,6 +36,13 @@ relay voice, desktop tooling, and remote access:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
**Official Docker image:** on `nousresearch/hermes-agent` the native
`hermes plugins install` path above is the only supported one. The image is
immutable (`/opt/hermes/.venv`, no git clone, no user systemd), so
`install.sh`'s editable-install/systemd path is not applicable there — the
installer detects that layout and steers back to the native path. Start the
relay inside the container with `hermes relay start`.
The optional legacy compatibility hook is managed separately:
```bash
+58 -23
View File
@@ -24,9 +24,12 @@ Error translation
from __future__ import annotations
import importlib
import json
import os
import sys
import time
import types
from pathlib import Path as FsPath
from typing import Any, Optional
from urllib.parse import urlparse
@@ -34,6 +37,45 @@ from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, Body, HTTPException, Path, Query
# ── Plugin-package bootstrap ──────────────────────────────────────────────
# hermes-agent's web server loads this file standalone via
# ``importlib.util.spec_from_file_location`` (no parent package), so relative
# imports cannot work here. The plugin package's import name also varies by
# install method: classic editable install = ``plugin``, native
# ``hermes plugins install`` = ``hermes_plugins.hermes_relay`` (issue #165).
# ``_plugin_module()`` resolves sibling plugin modules in every context:
#
# 1. Loaded as a submodule of the plugin package (tests, native loader) —
# import through the REAL parent package so there is a single module
# instance (test monkeypatching of e.g. ``plugin.relay.tailscale`` must
# stay effective).
# 2. Loaded standalone by the dashboard web server — synthesize the parent
# package: a bare ``ModuleType`` whose ``__path__`` points at the plugin
# directory, registered in ``sys.modules`` under a stable alias. The
# import system then resolves submodules against that path WITHOUT ever
# exec'ing ``plugin/__init__.py`` (whose tool registration side effects
# must not run inside the web server).
_PLUGIN_PKG_ALIAS = "_hermes_relay_plugin_pkg"
def _plugin_module(name: str) -> types.ModuleType:
"""Import ``<plugin package>.<name>`` in whatever layout we're running."""
if __package__ and "." in __package__:
# Assumes our parent package IS the plugin package — true for both real
# layouts: ``plugin.dashboard`` → ``plugin`` and
# ``hermes_plugins.hermes_relay.dashboard`` → ``hermes_plugins.hermes_relay``.
parent = __package__.rsplit(".", 1)[0] # strip trailing ".dashboard"
return importlib.import_module(f"{parent}.{name}")
pkg = sys.modules.get(_PLUGIN_PKG_ALIAS)
if pkg is None:
plugin_dir = FsPath(__file__).resolve().parent.parent
pkg = types.ModuleType(_PLUGIN_PKG_ALIAS)
pkg.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
pkg.__package__ = _PLUGIN_PKG_ALIAS
sys.modules[_PLUGIN_PKG_ALIAS] = pkg
return importlib.import_module(f"{_PLUGIN_PKG_ALIAS}.{name}")
# Read once at import time — hermes-agent restarts pick up env changes.
RELAY_PORT: int = int(os.environ.get("HERMES_RELAY_PORT", "8767"))
_RELAY_BASE: str = f"http://127.0.0.1:{RELAY_PORT}"
@@ -182,15 +224,12 @@ async def get_media(include_expired: Optional[bool] = Query(default=None)) -> An
@router.get("/agent-context")
async def get_agent_context() -> dict[str, Any]:
"""Return current Agent context flags and the relay audit payload."""
from plugin.config import (
agent_context_enabled,
context_media_sensitivity_enabled,
)
config = _plugin_module("config")
return {
"settings": {
"RELAY_AGENT_CONTEXT_ENABLED": agent_context_enabled(),
"RELAY_CONTEXT_MEDIA_SENSITIVITY": context_media_sensitivity_enabled(),
"RELAY_AGENT_CONTEXT_ENABLED": config.agent_context_enabled(),
"RELAY_CONTEXT_MEDIA_SENSITIVITY": config.context_media_sensitivity_enabled(),
},
"injected": await _proxy_get("/context/injected"),
}
@@ -208,16 +247,12 @@ async def get_phone_config() -> dict[str, Any]:
``PHONE_ENABLED`` gate so the tab can hide the card when the platform is
off. No relay round-trip — env is process-local.
"""
from plugin.phone_platform import (
_home_channel,
_home_channel_name,
_phone_enabled,
)
phone_platform = _plugin_module("phone_platform")
return {
"enabled": _phone_enabled(),
"home_channel_id": _home_channel(),
"home_channel_name": _home_channel_name(),
"enabled": phone_platform._phone_enabled(),
"home_channel_id": phone_platform._home_channel(),
"home_channel_name": phone_platform._home_channel_name(),
"name_env_key": "PHONE_HOME_CHANNEL_NAME",
}
@@ -239,7 +274,7 @@ async def get_update_check(refresh: Optional[bool] = Query(default=False)) -> di
releases API. Network failures degrade to ``update_available=false`` with
an ``error`` string — never a 5xx — so the card can show "couldn't check".
"""
from plugin import update_check
update_check = _plugin_module("update_check")
now = time.time()
stale = (now - _UPDATE_CACHE["fetched_at"]) > _UPDATE_CACHE_TTL
@@ -368,7 +403,9 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
# on sys.path (smoke tests, docs render, etc.) still loads the
# module. Any failure here becomes a 500 via HTTPException below.
try:
from plugin.pair import build_endpoint_candidates, read_relay_config
pair = _plugin_module("pair")
build_endpoint_candidates = pair.build_endpoint_candidates
read_relay_config = pair.read_relay_config
except ImportError as exc:
raise HTTPException(
status_code=500,
@@ -389,9 +426,7 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
# API defaults come from the same config chain ``pair.py`` uses so
# the dashboard-minted QR matches what ``hermes-pair --mode auto``
# would emit from the CLI.
from plugin.pair import read_server_config # local import: see above
api_cfg = read_server_config()
api_cfg = pair.read_server_config()
relay_cfg = read_relay_config()
api_host = str(body.get("host") or api_cfg.get("host") or "127.0.0.1")
api_port = int(body.get("port") or api_cfg.get("port") or 8642)
@@ -444,7 +479,7 @@ def _tailscale_status_dict() -> dict[str, Any]:
render a "not installed" state without a second round-trip.
"""
try:
from plugin.relay import tailscale
tailscale = _plugin_module("relay.tailscale")
except ImportError:
return {"available": False, "reason": "helper not importable"}
try:
@@ -458,7 +493,7 @@ def _tailscale_status_dict() -> dict[str, Any]:
def _canonical_upstream_present() -> bool:
try:
from plugin.relay import tailscale
tailscale = _plugin_module("relay.tailscale")
except ImportError:
return False
try:
@@ -496,7 +531,7 @@ async def tailscale_enable(
) -> dict[str, Any]:
"""Call ``tailscale.enable(port)`` and return its verbatim result."""
try:
from plugin.relay import tailscale
tailscale = _plugin_module("relay.tailscale")
except ImportError as exc:
raise HTTPException(
status_code=500,
@@ -519,7 +554,7 @@ async def tailscale_disable(
) -> dict[str, Any]:
"""Call ``tailscale.disable(port)`` and return its verbatim result."""
try:
from plugin.relay import tailscale
tailscale = _plugin_module("relay.tailscale")
except ImportError as exc:
raise HTTPException(
status_code=500,
+41
View File
@@ -9,6 +9,7 @@ the legacy bootstrap monkeypatch is still installed.
from __future__ import annotations
import argparse
import importlib
import json
import os
import site
@@ -191,6 +192,29 @@ def _plugin_manager_layout(plugin_dir: Path) -> dict[str, Any]:
}
def _relay_import_chain() -> dict[str, Any]:
"""Import the relay server module chain under the CURRENT package layout.
``hermes relay start`` runs ``create_app`` from ``<plugin pkg>.relay.server``,
which transitively pulls the voice/realtime/voice_lab modules. Actually
importing that chain here catches the failure class from issue #165 —
absolute ``plugin.*`` imports crashing when the native plugin loader
imports the tree as ``hermes_plugins.hermes_relay`` (no top-level
``plugin`` package exists) — which a filesystem-layout check can never see.
"""
package = __package__ or "plugin"
module_name = f"{package}.relay.server"
try:
importlib.import_module(module_name)
except Exception as exc: # any import-time failure is a real start failure
return {
"ok": False,
"module": module_name,
"error": f"{type(exc).__name__}: {exc}",
}
return {"ok": True, "module": module_name, "error": None}
def _check(checks: list[dict[str, str]], check_id: str, status: str, summary: str) -> None:
checks.append({"id": check_id, "status": status, "summary": summary})
@@ -230,6 +254,7 @@ def collect_doctor_report(
layout = _plugin_manager_layout(PLUGIN_DIR)
bootstrap = _bootstrap_status(site_dirs)
relay_import = _relay_import_chain()
checks: list[dict[str, str]] = []
_check(
@@ -276,6 +301,21 @@ def collect_doctor_report(
if dashboard_ws_ticket.get("exists")
else "dashboard WebSocket ticket route was not detected",
)
_check(
checks,
"relay-import-chain",
"ok" if relay_import["ok"] else "error",
f"relay server module chain imports cleanly ({relay_import['module']})"
if relay_import["ok"]
else (
f"importing {relay_import['module']} failed "
f"({relay_import['error']}) — `hermes relay start` will crash. "
"If the error names a missing 'plugin' module, this plugin build "
"predates native-layout support; update it "
"(hermes plugins install Codename-11/hermes-relay/plugin) or "
"reinstall with install.sh."
),
)
_check(
checks,
"relay-loopback",
@@ -313,6 +353,7 @@ def collect_doctor_report(
"relay": {
"port": int(port),
"info": relay_info,
"import_chain": relay_import,
},
"bootstrap": bootstrap,
"lifecycle": {
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from plugin.enhancements.context_injection import CONTEXT_INJECTION_ENHANCEMENT
from plugin.enhancements.registry import Enhancement, EnhancementPhase, apply_enhancements, filter_enhancements
from .context_injection import CONTEXT_INJECTION_ENHANCEMENT
from .registry import Enhancement, EnhancementPhase, apply_enhancements, filter_enhancements
_ENHANCEMENTS: list[Enhancement] = [
CONTEXT_INJECTION_ENHANCEMENT,
+2 -2
View File
@@ -14,13 +14,13 @@ import sys
from functools import wraps
from typing import Any
from plugin.config import (
from ..config import (
agent_context_enabled,
context_media_sensitivity_enabled,
context_phone_platform_enabled,
phone_platform_enabled,
)
from plugin.enhancements.registry import Enhancement
from .registry import Enhancement
logger = logging.getLogger(__name__)
+3 -3
View File
@@ -73,7 +73,7 @@ from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
from plugin.relay.qr_sign import load_or_create_secret, sign_payload
from .relay.qr_sign import load_or_create_secret, sign_payload
def _get_hermes_home() -> Path:
@@ -360,7 +360,7 @@ def _tailscale_status() -> Optional[dict[str, Any]]:
hostname we surface it as a ``role: tailscale`` candidate.
"""
try:
from plugin.relay import tailscale # type: ignore
from .relay import tailscale # type: ignore
except ImportError:
return None
try:
@@ -691,7 +691,7 @@ def build_endpoint_candidates(
# port, or the JSON parse hits an unexpected shape.
if not effective_public_url:
try:
from plugin.relay import tailscale as _ts_helper # type: ignore
from .relay import tailscale as _ts_helper # type: ignore
detected = _ts_helper.funnel_url(port=relay_port)
except Exception: # noqa: BLE001 — any failure = no funnel
+1 -1
View File
@@ -13,7 +13,7 @@ from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from plugin.voice_lab.auth import load_voice_lab_env_file
from ..voice_lab.auth import load_voice_lab_env_file
OPTION_FETCH_TIMEOUT_SECONDS = 5.0
DEFAULT_CACHE_TTL_SECONDS = 600
+3 -3
View File
@@ -27,9 +27,9 @@ from typing import Any
from aiohttp import WSMsgType, web
from plugin.voice_lab.auth import load_voice_lab_env_file
from plugin.voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from plugin.voice_lab.registry import default_registry
from ...voice_lab.auth import load_voice_lab_env_file
from ...voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ...voice_lab.registry import default_registry
from ..config import (
RelayConfig,
@@ -8,10 +8,10 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol
from plugin.voice_lab.expressions import VoiceExpression
from plugin.voice_lab.metrics import MetricsRecorder
from plugin.voice_lab.providers.base import VoiceRequest, VoiceResponse
from plugin.voice_lab.registry import default_registry
from ....voice_lab.expressions import VoiceExpression
from ....voice_lab.metrics import MetricsRecorder
from ....voice_lab.providers.base import VoiceRequest, VoiceResponse
from ....voice_lab.registry import default_registry
from ..models import (
ProviderEvent,
@@ -12,8 +12,8 @@ from typing import Any, Protocol
import aiohttp
from plugin.voice_lab.auth import load_voice_lab_env_file
from plugin.voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ....voice_lab.auth import load_voice_lab_env_file
from ....voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ..models import (
ProviderEvent,
+2 -2
View File
@@ -12,12 +12,12 @@ from typing import Any, Protocol
import aiohttp
from plugin.voice_lab.auth import (
from ....voice_lab.auth import (
VoiceLabAuthError,
load_voice_lab_env_file,
read_xai_oauth_token,
)
from plugin.voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ....voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ..models import (
ProviderEvent,
+4 -4
View File
@@ -19,14 +19,14 @@ from typing import Any
from aiohttp import web, WSMsgType
from plugin.voice_lab.expressions import VoiceExpression
from plugin.voice_lab.metrics import MetricsRecorder
from plugin.voice_lab.providers.base import (
from ..voice_lab.expressions import VoiceExpression
from ..voice_lab.metrics import MetricsRecorder
from ..voice_lab.providers.base import (
ProviderRunError,
ProviderUnavailable,
VoiceRequest,
)
from plugin.voice_lab.registry import default_registry
from ..voice_lab.registry import default_registry
from .config import (
RelayConfig,
+1 -1
View File
@@ -22,7 +22,7 @@ import json
import sys
from typing import Any
from plugin.relay import tailscale
from . import tailscale
def _print_result(result: dict[str, Any] | None, *, as_json: bool) -> int:
+4 -4
View File
@@ -24,14 +24,14 @@ from typing import Any
from aiohttp import WSMsgType, web
from plugin.voice_lab.expressions import VoiceExpression
from plugin.voice_lab.metrics import MetricsRecorder
from plugin.voice_lab.providers.base import (
from ..voice_lab.expressions import VoiceExpression
from ..voice_lab.metrics import MetricsRecorder
from ..voice_lab.providers.base import (
ProviderRunError,
ProviderUnavailable,
VoiceRequest,
)
from plugin.voice_lab.registry import default_registry
from ..voice_lab.registry import default_registry
from .config import (
RelayConfig,
+181
View File
@@ -0,0 +1,181 @@
"""Guards against absolute ``plugin.*`` imports breaking the native layout.
hermes-agent's native plugin installer (``hermes plugins install
Codename-11/hermes-relay/plugin``) loads the plugin directory as
``hermes_plugins.hermes_relay`` — there is NO top-level ``plugin`` package in
that environment, so any absolute ``from plugin.X import ...`` crashes with
``ModuleNotFoundError: No module named 'plugin'`` (issue #165).
Two layers of defence:
1. An AST guard walking every runtime module under ``plugin/`` asserting no
absolute ``plugin.``-imports exist (test modules are exempt — they run
from the repo root where ``plugin`` is importable).
2. A native-layout smoke test that copies the plugin tree to a tempdir under
a DIFFERENT package name and, in a subprocess where the repo-root
``plugin`` package is unreachable, loads it exactly the way upstream's
``PluginManager._load_directory_module`` does, then imports the relay
server module chain.
"""
from __future__ import annotations
import ast
import os
import shutil
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
PLUGIN_DIR = Path(__file__).resolve().parent.parent
def _runtime_python_files() -> list[Path]:
"""Every .py under plugin/ except test code and caches."""
files: list[Path] = []
for path in sorted(PLUGIN_DIR.rglob("*.py")):
rel = path.relative_to(PLUGIN_DIR)
if "tests" in rel.parts or "__pycache__" in rel.parts:
continue
if path.name.startswith("test_"):
# e.g. plugin/dashboard/test_plugin_api.py — runs from the repo
# root where the classic ``plugin`` package IS importable.
continue
files.append(path)
return files
class AbsolutePluginImportGuardTest(unittest.TestCase):
"""No runtime module may import the top-level ``plugin`` package."""
def test_no_absolute_plugin_imports_remain(self) -> None:
offenders: list[str] = []
for path in _runtime_python_files():
rel = path.relative_to(PLUGIN_DIR)
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "plugin" or alias.name.startswith("plugin."):
offenders.append(f"{rel}:{node.lineno}: import {alias.name}")
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
if node.level == 0 and (
module == "plugin" or module.startswith("plugin.")
):
offenders.append(f"{rel}:{node.lineno}: from {module} import ...")
self.assertEqual(
offenders,
[],
"Absolute plugin.* imports break the native hermes-agent plugin "
"loader, which imports this tree as hermes_plugins.hermes_relay "
"(no top-level 'plugin' package exists there — issue #165). Use "
"package-relative imports instead:\n " + "\n ".join(offenders),
)
# Mirrors PluginManager._load_directory_module in hermes-agent's
# hermes_cli/plugins.py: synthesize the hermes_plugins namespace parent, then
# spec_from_file_location the plugin __init__ with submodule_search_locations.
_SMOKE_SCRIPT = textwrap.dedent(
"""
import importlib
import importlib.util
import sys
import types
from pathlib import Path
PLUGIN_DIR = Path(sys.argv[1])
# Fail LOUDLY if anything still references the top-level ``plugin``
# package. Without this blocker an editable hermes-relay install in the
# running interpreter's site-packages could satisfy a stray absolute
# import and mask the regression this smoke test exists to catch.
class _BlockTopLevelPlugin:
def find_spec(self, fullname, path=None, target=None):
if fullname == "plugin" or fullname.startswith("plugin."):
raise ModuleNotFoundError(
"top-level 'plugin' import attempted under the native "
f"layout: {fullname}"
)
return None
sys.meta_path.insert(0, _BlockTopLevelPlugin())
NS_PARENT = "hermes_plugins"
SLUG = "hermes_relay_native_smoke"
MODULE_NAME = f"{NS_PARENT}.{SLUG}"
ns_pkg = types.ModuleType(NS_PARENT)
ns_pkg.__path__ = []
ns_pkg.__package__ = NS_PARENT
sys.modules[NS_PARENT] = ns_pkg
init_file = PLUGIN_DIR / "__init__.py"
spec = importlib.util.spec_from_file_location(
MODULE_NAME,
init_file,
submodule_search_locations=[str(PLUGIN_DIR)],
)
module = importlib.util.module_from_spec(spec)
module.__package__ = MODULE_NAME
module.__path__ = [str(PLUGIN_DIR)]
sys.modules[MODULE_NAME] = module
spec.loader.exec_module(module)
# The chain `hermes relay start` needs (cli -> relay.server -> voice /
# realtime / voice_lab), plus the pairing CLI.
for target in ("relay.server", "relay.tailscale_cli", "pair", "doctor", "enhancements"):
importlib.import_module(f"{MODULE_NAME}.{target}")
print("NATIVE-LAYOUT-IMPORTS-OK")
"""
)
class NativeLayoutSmokeTest(unittest.TestCase):
"""Import the plugin the way upstream's directory loader does."""
def test_relay_server_chain_imports_under_native_loader(self) -> None:
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
plugin_copy = tmp / "plugin_tree"
shutil.copytree(
PLUGIN_DIR,
plugin_copy,
ignore=shutil.ignore_patterns(
"tests", "__pycache__", "*.pyc", "node_modules", "dist"
),
)
script = tmp / "run_smoke.py"
script.write_text(_SMOKE_SCRIPT, encoding="utf-8")
# Clean import environment: cwd is the tempdir (sys.path[0] will
# be the tempdir, where no ``plugin`` package exists) and no
# inherited PYTHONPATH can leak the repo root in.
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
result = subprocess.run(
[sys.executable, str(script), str(plugin_copy)],
cwd=str(tmp),
env=env,
capture_output=True,
text=True,
timeout=120,
)
self.assertEqual(
result.returncode,
0,
"native-layout import smoke failed — the plugin does not import "
"under hermes-agent's directory loader (hermes_plugins.<slug>):\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
)
self.assertIn("NATIVE-LAYOUT-IMPORTS-OK", result.stdout)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -225,7 +225,7 @@ def _capture_screenshot() -> _Screenshot:
# graceful degradation as android_screenshot).
token_marker = f"file://{tmp.name}"
try:
from plugin.relay.client import register_media # type: ignore
from ..relay.client import register_media # type: ignore
token = register_media(tmp.name, "image/jpeg", file_name="nav_step.jpg")
if token:
+7 -3
View File
@@ -59,7 +59,11 @@ try:
from_bridge_response,
)
except ImportError: # pragma: no cover - direct-script fallback
from plugin.tools.resolve_result import ( # type: ignore[no-redef]
# sys.path[0] is this file's directory when run as a plain script, so the
# sibling module resolves top-level. Never an absolute `plugin.` import:
# under the native hermes-agent plugin loader the package is
# `hermes_plugins.hermes_relay` and no top-level `plugin` exists (#165).
from resolve_result import ( # type: ignore[no-redef]
Found,
NotFound,
PermissionDenied,
@@ -480,7 +484,7 @@ def android_screenshot(sensitive: bool = False) -> str:
# same host (it's loopback-only). Any failure falls back to the
# bare path form — the phone shows a placeholder in that case.
try:
from plugin.relay.client import register_media
from ..relay.client import register_media
token = register_media(
tmp.name,
"image/jpeg",
@@ -986,7 +990,7 @@ def _register_attachment_path(
resolved_type = _guess_content_type(str(source), content_type)
resolved_name = file_name or source.name
from plugin.relay.client import register_media
from ..relay.client import register_media
token = register_media(
str(source),
+7
View File
@@ -26,6 +26,13 @@ StartLimitBurst=5
[Service]
Type=simple
# The %h/.hermes/hermes-agent/venv paths below are the CLASSIC (pip/git) venv
# layout — the default template. install.sh rewrites every
# "%h/.hermes/hermes-agent/venv" occurrence in the copy it drops at
# ~/.config/systemd/user/hermes-relay.service to the venv it actually detected
# (uv-managed ".venv", a HERMES_VENV_PY override, etc.) so ExecStart / PATH /
# VIRTUAL_ENV always point at a real interpreter instead of failing 203/EXEC.
# Copying this file by hand keeps the classic paths — edit them to match.
ExecStart=%h/.hermes/hermes-agent/venv/bin/python -m plugin.relay --no-ssl --log-level INFO
WorkingDirectory=%h/.hermes/hermes-relay
Environment="PATH=%h/.hermes/hermes-agent/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+11
View File
@@ -63,6 +63,17 @@ That path manages plugin code, CLI command registration, dashboard metadata, and
agent tools. It does not install the systemd user service or shell shims. Use the
legacy `install.sh` only when you want those host-level artifacts.
### Official Docker image
On the official `nousresearch/hermes-agent` Docker image, the native install
path above is the **only** supported one. The image is immutable: hermes-agent
lives in `/opt/hermes/.venv` with no git clone, no user systemd, and
site-packages that reset on the next `docker pull` — so `install.sh`'s
editable-install/systemd path is not applicable there (the installer detects
this layout and points you back to `hermes plugins install`). Run the relay
inside the container with `hermes relay start` (or your own process
supervisor) after installing and enabling the plugin.
The optional compatibility startup hook is managed by the plugin:
```bash