Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
357723392d | ||
|
|
8e96a019a2 | ||
|
|
ba18fe95c0 | ||
|
|
70015beb81 | ||
|
|
5a44d4519a | ||
|
|
fcbf5666e4 | ||
|
|
865c39bd86 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user