- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5 - app/: Compose Multiplatform project (shared KMP + androidApp + desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug and :desktopApp:compileKotlin - scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/ is staged (read-only reference, never committed) - .gitignore excludes hermes-agent/; docs/ reference library
471 lines
18 KiB
Python
471 lines
18 KiB
Python
"""
|
|
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
|
|
|
A plugin-based gateway adapter that runs a WebSocket server *inside* the
|
|
``hermes gateway`` process. The native Android / Desktop app connects to it
|
|
with a pairing token and talks to the agent over a single WS transport
|
|
(chat, streaming, tools, media, pairing, push-token).
|
|
|
|
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core
|
|
deps. Zero hermes-core changes.
|
|
|
|
Milestone M0: this is a *skeleton* adapter. It registers the ``android``
|
|
platform, resolves its configuration, and implements the abstract adapter
|
|
contract as no-ops so that ``hermes gateway status`` lists ``android``. The
|
|
WebSocket server, pairing, streaming, media, outbox, push, and search are
|
|
wired in later milestones (see ``docs/14-milestones.md``).
|
|
|
|
Configuration in config.yaml::
|
|
|
|
gateway:
|
|
platforms:
|
|
android:
|
|
enabled: true
|
|
extra:
|
|
host: 127.0.0.1
|
|
port: 8790
|
|
home_channel: android:default
|
|
push_backend: fcm
|
|
outbox_retention_hours: 72
|
|
max_upload_bytes: 104857600
|
|
|
|
Or via environment variables (overrides config.yaml; secrets live in .env):
|
|
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL,
|
|
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
|
|
from agent.secret_scope import get_secret as _scoped_get_secret
|
|
|
|
|
|
def _get_scoped_secret(name, default=None):
|
|
"""Scope-aware credential read with the default-profile startup fallback.
|
|
|
|
Secondary profiles construct their adapters under a profile secret scope
|
|
-- the scope is authoritative and a scoped miss returns ``default`` (no
|
|
cross-profile borrow from ``os.environ``, which may hold another
|
|
profile's value). The DEFAULT profile's adapter constructs and sends
|
|
*unscoped* under multiplexing, where a bare ``get_secret`` would raise
|
|
``UnscopedSecretError`` and crash this path; there ``os.environ`` is that
|
|
profile's own value, so fall back to it. Same pattern as the IRC
|
|
``IRC_SERVER_PASSWORD`` read (``plugins/platforms/irc/adapter.py``).
|
|
"""
|
|
try:
|
|
val = _scoped_get_secret(name, default)
|
|
except _UnscopedSecretError:
|
|
val = os.getenv(name)
|
|
return val if val is not None else default
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lazy import: BasePlatformAdapter and friends live in the main repo.
|
|
# We import at module level (as the bundled plugins do) but guard the heavy
|
|
# gateway imports so the plugin can be discovered before the gateway is fully
|
|
# initialised.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from gateway.platforms.base import ( # noqa: E402
|
|
BasePlatformAdapter,
|
|
SendResult,
|
|
MessageEvent,
|
|
MessageType,
|
|
)
|
|
from gateway.config import Platform # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defaults
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 8790
|
|
DEFAULT_HOME_CHANNEL = "android:default"
|
|
DEFAULT_PUSH_BACKEND = "fcm"
|
|
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
|
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
|
|
|
|
def _truthy(value: Optional[str]) -> bool:
|
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Passive / config probes (called from status displays -- no side effects)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_requirements() -> bool:
|
|
"""PASSIVE dependency probe: ``websockets`` importable + token set.
|
|
|
|
Must be side-effect free (called from ``hermes setup`` / ``status`` /
|
|
dashboard readiness). Never installs.
|
|
"""
|
|
try:
|
|
import websockets # noqa: F401 (core dep)
|
|
except Exception:
|
|
return False
|
|
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
|
|
|
|
|
def validate_config(config) -> bool:
|
|
"""Given a PlatformConfig, is the platform properly configured?"""
|
|
extra = getattr(config, "extra", {}) or {}
|
|
token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
|
return bool(token)
|
|
|
|
|
|
def is_connected(config) -> bool:
|
|
"""Is the platform configured (env or config.yaml)?"""
|
|
return validate_config(config)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Env-driven auto-configuration (seeds PlatformConfig.extra pre-adapter)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _env_enablement() -> Optional[dict]:
|
|
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
|
|
|
Called by the platform registry's env-enablement hook BEFORE adapter
|
|
construction, so ``gateway status`` and ``get_connected_platforms()``
|
|
reflect env-only configuration without instantiating the adapter.
|
|
Returns ``None`` when the platform isn't minimally configured (no token);
|
|
the caller then skips auto-enabling.
|
|
|
|
The special ``home_channel`` key in the returned dict is handled by the
|
|
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
|
``PlatformConfig`` rather than being merged into ``extra``.
|
|
"""
|
|
token = _get_scoped_secret("ANDROID_TOKEN", "")
|
|
if not token:
|
|
return None
|
|
|
|
seed: Dict[str, Any] = {
|
|
"host": os.getenv("ANDROID_WS_HOST", "").strip() or DEFAULT_HOST,
|
|
"port": _parse_port(os.getenv("ANDROID_WS_PORT", "")),
|
|
"push_backend": (
|
|
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
|
or DEFAULT_PUSH_BACKEND
|
|
),
|
|
}
|
|
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() or DEFAULT_HOME_CHANNEL
|
|
seed["home_channel"] = {
|
|
"chat_id": home,
|
|
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or "Default",
|
|
}
|
|
return seed
|
|
|
|
|
|
def _parse_port(raw: str) -> int:
|
|
try:
|
|
return int((raw or "").strip())
|
|
except (ValueError, TypeError):
|
|
return DEFAULT_PORT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Target parsing: "android:<chat>[:<thread>]"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_target_ref(target_ref: str) -> Optional[tuple]:
|
|
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
|
|
|
Recognises the native syntax ``android:<chat>[:<thread>]``. Returns
|
|
``None`` for anything else so the target proceeds to channel-directory
|
|
resolution.
|
|
"""
|
|
if not target_ref or not target_ref.startswith("android:"):
|
|
return None
|
|
body = target_ref[len("android:"):]
|
|
if not body:
|
|
return None
|
|
if ":" in body:
|
|
chat_id, thread_id = body.split(":", 1)
|
|
thread_id = thread_id or None
|
|
else:
|
|
chat_id, thread_id = body, None
|
|
chat_id = chat_id.strip()
|
|
if not chat_id:
|
|
return None
|
|
return (chat_id, thread_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Standalone (out-of-process) send -- best-effort, stretch for v1
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _standalone_send(
|
|
pconfig,
|
|
chat_id: str,
|
|
message: str,
|
|
*,
|
|
thread_id: Optional[str] = None,
|
|
media_files: Optional[List[str]] = None,
|
|
force_document: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""Out-of-process delivery for cron jobs that run separately from the
|
|
gateway.
|
|
|
|
The outbox is served by the *running* gateway, so standalone delivery
|
|
while the gateway process is fully down is best-effort only (see
|
|
``docs/00-overview.md`` "Out of scope"). For M0 this is a stub that
|
|
reports the gateway is required; the real implementation lands with the
|
|
outbox (M3/M5).
|
|
"""
|
|
return {
|
|
"error": (
|
|
"android standalone send: the running gateway is required to serve "
|
|
"the outbox (standalone delivery is best-effort only)"
|
|
)
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive setup (hermes gateway setup flow) -- full version in M1
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def interactive_setup() -> None:
|
|
"""Prompt for the pairing token / host / port / push backend.
|
|
|
|
M0: minimal. M1 adds token generation, QR payload, and a live ``hello``
|
|
connectivity test.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import (
|
|
get_env_value,
|
|
save_env_value,
|
|
prompt,
|
|
print_info,
|
|
print_success,
|
|
print_warning,
|
|
)
|
|
except Exception:
|
|
print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env")
|
|
return
|
|
|
|
print_info("📱 Android / Desktop (Iris x Hermes)")
|
|
token = get_env_value("ANDROID_TOKEN") or ""
|
|
if not token:
|
|
generated = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
|
|
save_env_value("ANDROID_TOKEN", generated)
|
|
print_success(f"Generated pairing token: {generated}")
|
|
print_warning("Keep this secret -- the app presents it on connect.")
|
|
else:
|
|
print_info("Existing ANDROID_TOKEN found (not shown).")
|
|
|
|
host = prompt("WS bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
|
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
|
port = prompt("WS port", default=str(_parse_port(get_env_value("ANDROID_WS_PORT") or "")))
|
|
save_env_value("ANDROID_WS_PORT", str(_parse_port(port)))
|
|
backend = prompt("Push backend (fcm/ntfy)", default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND)
|
|
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
|
|
|
print_success("Android configuration saved to ~/.hermes/.env")
|
|
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Android Adapter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AndroidAdapter(BasePlatformAdapter):
|
|
"""WebSocket-backed adapter for the native Iris Android / Desktop app.
|
|
|
|
M0: skeleton. Implements the abstract adapter contract as no-ops and
|
|
resolves configuration. The WebSocket server, connection registry,
|
|
pairing, streaming, media, outbox, push, and search are added in later
|
|
milestones.
|
|
"""
|
|
|
|
def __init__(self, config, **kwargs):
|
|
platform = Platform("android")
|
|
super().__init__(config=config, platform=platform)
|
|
|
|
extra = getattr(config, "extra", {}) or {}
|
|
|
|
# Connection settings (env vars override config.yaml)
|
|
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
|
self.port = _parse_port(os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT)))
|
|
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
|
self.home_channel = extra.get("home_channel", DEFAULT_HOME_CHANNEL)
|
|
self.push_backend = (
|
|
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
|
or extra.get("push_backend", DEFAULT_PUSH_BACKEND)
|
|
)
|
|
self.outbox_retention_hours = int(
|
|
extra.get("outbox_retention_hours", DEFAULT_OUTBOX_RETENTION_HOURS)
|
|
)
|
|
self.max_upload_bytes = int(
|
|
extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES)
|
|
)
|
|
|
|
# TLS (optional)
|
|
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
|
|
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
|
|
|
|
# Auth
|
|
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
|
self.allowed_users: List[str] = (
|
|
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
|
|
)
|
|
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
|
|
|
|
# Runtime state (populated by the WS server in M1)
|
|
self._ws_server = None
|
|
self._connections: Dict[str, Any] = {}
|
|
self._connected = False
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "Android"
|
|
|
|
# ── Connection lifecycle ──────────────────────────────────────────────
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
"""Bring the platform up.
|
|
|
|
M0: no WebSocket server yet -- just validate config and mark
|
|
connected so ``hermes gateway status`` reflects the platform. M1
|
|
starts the ``websockets`` server here.
|
|
"""
|
|
if not self.token:
|
|
logger.error("android: ANDROID_TOKEN must be set")
|
|
self._set_fatal_error(
|
|
"config_missing",
|
|
"ANDROID_TOKEN must be set",
|
|
retryable=False,
|
|
)
|
|
return False
|
|
|
|
# Prevent two profiles from binding the same port/identity.
|
|
try:
|
|
from gateway.status import acquire_scoped_lock
|
|
lock_key = f"{self.host}:{self.port}"
|
|
if not acquire_scoped_lock("android", lock_key):
|
|
logger.error("android: %s:%s already in use by another profile", self.host, self.port)
|
|
self._set_fatal_error(
|
|
"lock_conflict",
|
|
"WS port in use by another profile",
|
|
retryable=False,
|
|
)
|
|
return False
|
|
self._lock_key = lock_key
|
|
except ImportError:
|
|
self._lock_key = None # status module not available (e.g. tests)
|
|
|
|
# M1: start the websockets server on host:port (TLS if cert/key set).
|
|
self._connected = True
|
|
self._mark_connected()
|
|
logger.info("android: connected (skeleton; WS server starts in M1) on %s:%s", self.host, self.port)
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Tear down the platform."""
|
|
try:
|
|
from gateway.status import release_scoped_lock
|
|
if getattr(self, "_lock_key", None):
|
|
release_scoped_lock("android", self._lock_key)
|
|
except ImportError:
|
|
pass
|
|
# M1: stop the server and close all device sockets.
|
|
self._connected = False
|
|
self._mark_disconnected()
|
|
logger.info("android: disconnected")
|
|
|
|
# ── Outbound (agent -> app) ───────────────────────────────────────────
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send a message to a chat.
|
|
|
|
M0: no live devices yet -- log and report success with a minted id.
|
|
M1: broadcast a ``message`` frame to connected devices, else fall to
|
|
the outbox + fire push.
|
|
"""
|
|
message_id = f"msg_{uuid.uuid4().hex}"
|
|
logger.debug("android: send to %s (%d chars) [skeleton no-op]", chat_id, len(content or ""))
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
"""Send a typing indicator. M0: no-op (M1 emits a ``typing`` frame)."""
|
|
return None
|
|
|
|
async def send_image(
|
|
self,
|
|
chat_id: str,
|
|
image_url: str,
|
|
caption: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send an image. M0: not implemented (M4)."""
|
|
return SendResult(success=False, error="android: media not implemented yet (M4)")
|
|
|
|
# ── Chat info ─────────────────────────────────────────────────────────
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Return ``{name, type, chat_id}`` for a chat.
|
|
|
|
M0: the channel directory is not persisted yet, so report the home
|
|
channel name for the default chat and a generic name otherwise.
|
|
"""
|
|
name = "Default" if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL) else (chat_id or "chat")
|
|
return {"name": name, "type": "channel", "chat_id": chat_id}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register(ctx):
|
|
"""Plugin entry point: called by the Hermes plugin system."""
|
|
ctx.register_platform(
|
|
name="android",
|
|
label="Android",
|
|
adapter_factory=lambda cfg: AndroidAdapter(cfg),
|
|
check_fn=check_requirements,
|
|
validate_config=validate_config,
|
|
is_connected=is_connected,
|
|
required_env=["ANDROID_TOKEN"],
|
|
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
|
setup_fn=interactive_setup,
|
|
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
|
# host/port/push_backend + home_channel so env-only setups show up in
|
|
# gateway status without instantiating the adapter.
|
|
env_enablement_fn=_env_enablement,
|
|
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]).
|
|
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
|
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
|
|
standalone_sender_fn=_standalone_send,
|
|
# Native target syntax: "android:<chat>[:<thread>]".
|
|
parse_target_ref_fn=_parse_target_ref,
|
|
# Auth env vars for _is_user_authorized() integration.
|
|
allowed_users_env="ANDROID_ALLOWED_USERS",
|
|
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
|
# WS has no message-size limit.
|
|
max_message_length=0,
|
|
# Display.
|
|
emoji="📱",
|
|
pii_safe=False,
|
|
allow_update_command=True,
|
|
# LLM guidance.
|
|
platform_hint=(
|
|
"You are chatting with the user through their native Iris app "
|
|
"(Android/Desktop). It renders Markdown, inline code, images, "
|
|
"audio and video, and shows your reasoning and tool activity. "
|
|
"Conversations are organized into channels and optional threads. "
|
|
"Keep formatting rich but readable."
|
|
),
|
|
) |