M0: toolchain, monorepo scaffold, gateway plugin skeleton, CMP app
- 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
This commit is contained in:
commit
59acf66c89
49 files changed
+3950
No files matched your search
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
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."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Inbound media cache + outbound chunked streaming.
|
||||
|
||||
Inbound: ``media.upload`` (chunked binary frames) -> ``cache_*_from_bytes``
|
||||
-> a ``media_ref`` the adapter attaches to the ``MessageEvent``. Enforces
|
||||
size limit + sha256 + MIME re-sniff.
|
||||
|
||||
Outbound: ``send_*`` -> stage the file in the media cache, mint a
|
||||
``media_id``, emit ``media.offer {media_id, mime, size, filename, kind}``;
|
||||
serve bytes on ``media.pull`` as chunked binary frames. Delivery-path
|
||||
security via ``validate_media_delivery_path``.
|
||||
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` where possible.
|
||||
All paths under ``get_hermes_home()/"android"/media``.
|
||||
|
||||
Milestone M4.
|
||||
"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""SQLite offline outbox + monotonic sync cursor.
|
||||
|
||||
Undelivered frames are appended per ``chat_id`` with a monotonic cursor so a
|
||||
reconnecting app can ``sync {cursor}`` the delta without re-reading full
|
||||
history. Retention prunes old entries (``outbox_retention_hours``).
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/outbox.db``.
|
||||
|
||||
Milestone M3 (built), extended in M5 (push integration).
|
||||
"""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Pairing: token generation/verification + device registry.
|
||||
|
||||
Token generation (64-hex) and constant-time verification. Device registry
|
||||
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
|
||||
created. QR payload for the pairing flow (``interactive_setup``).
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/devices.db``.
|
||||
|
||||
Milestone M1.
|
||||
"""
|
||||
@@ -0,0 +1,67 @@
|
||||
name: android-platform
|
||||
label: Android
|
||||
kind: platform
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Native Android / Desktop client gateway adapter for Hermes Agent.
|
||||
Runs a WebSocket server inside the gateway; the app connects with a
|
||||
pairing token. Supports streaming, reasoning, structured tool events,
|
||||
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
||||
author: Iris x Hermes
|
||||
# ``requires_env`` / ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
||||
# env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: ANDROID_TOKEN
|
||||
description: "Shared pairing token the app presents on connect"
|
||||
prompt: "Android pairing token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: ANDROID_WS_HOST
|
||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "WS host"
|
||||
password: false
|
||||
- name: ANDROID_WS_PORT
|
||||
description: "WS port (default 8790)"
|
||||
prompt: "WS port"
|
||||
password: false
|
||||
- name: ANDROID_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default android:default)"
|
||||
prompt: "Home channel"
|
||||
password: false
|
||||
- name: ANDROID_ALLOWED_USERS
|
||||
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
||||
prompt: "Allowed device ids"
|
||||
password: false
|
||||
- name: ANDROID_ALLOW_ALL_USERS
|
||||
description: "Allow any paired device (dev only)"
|
||||
prompt: "Allow all devices? (true/false)"
|
||||
password: false
|
||||
- name: ANDROID_PUSH_BACKEND
|
||||
description: "Push backend: fcm (default) or ntfy"
|
||||
prompt: "Push backend"
|
||||
password: false
|
||||
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
||||
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
||||
prompt: "FCM service account path"
|
||||
password: true
|
||||
- name: ANDROID_FCM_SERVER_KEY
|
||||
description: "Legacy FCM server key (fallback if no service account)"
|
||||
prompt: "FCM server key"
|
||||
password: true
|
||||
- name: NTFY_TOPIC
|
||||
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
||||
prompt: "ntfy topic"
|
||||
password: false
|
||||
- name: NTFY_SERVER_URL
|
||||
description: "ntfy server URL (default https://ntfy.sh)"
|
||||
prompt: "ntfy server URL"
|
||||
password: false
|
||||
- name: ANDROID_WS_CERT
|
||||
description: "TLS cert path for WSS (optional)"
|
||||
prompt: "WSS cert"
|
||||
password: false
|
||||
- name: ANDROID_WS_KEY
|
||||
description: "TLS key path for WSS (optional)"
|
||||
prompt: "WSS key"
|
||||
password: false
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Frame schemas -- the single source of truth for the wire protocol.
|
||||
|
||||
Every frame the plugin sends/receives is modelled here as a dataclass +
|
||||
constants. ``docs/protocol/frames.schema.json`` is generated/mirrored from
|
||||
this module, and the Kotlin side mirrors these shapes (see
|
||||
``docs/04-wire-protocol.md``).
|
||||
|
||||
Milestone M1: hello/hello.ack, message, error, ping/pong.
|
||||
Milestone M2: message.start/update/stop, reasoning, tool.*, commentary, typing.
|
||||
Milestone M3: channel.*, search, sync.
|
||||
Milestone M4: media.*.
|
||||
Milestone M5: notification, fcm.register, read.receipt.
|
||||
"""
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
# Frame type constants (the ``type`` field of every frame).
|
||||
TYPE_HELLO = "hello"
|
||||
TYPE_HELLO_ACK = "hello.ack"
|
||||
TYPE_ERROR = "error"
|
||||
TYPE_PING = "ping"
|
||||
TYPE_PONG = "pong"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Push backends: FCM (primary) + ntfy (fallback).
|
||||
|
||||
``PushBackend`` interface with two implementations:
|
||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||
(``ANDROID_FCM_SERVER_KEY``).
|
||||
- ``NtfyBackend``: reuses hermes ntfy publish (``NTFY_TOPIC`` /
|
||||
``NTFY_SERVER_URL``).
|
||||
|
||||
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
||||
Fired when a frame has no live subscriber; data payload drives a silent sync
|
||||
on the device.
|
||||
|
||||
Milestone M5.
|
||||
"""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""FTS5 session search bridge.
|
||||
|
||||
Bridges the ``search {query, scope, chat_id?, thread_id?}`` frame to the
|
||||
hermes session store (SQLite + FTS5, ``hermes_state_search.py``) and returns
|
||||
``search.results``. Scope: "all" (everywhere) or "chat" (this chat/channel).
|
||||
|
||||
Milestone M3.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tests for the android gateway plugin.
|
||||
|
||||
Run via hermes's hermetic runner (never bare pytest)::
|
||||
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
See ``docs/13-testing.md`` for the scenario list.
|
||||
@@ -0,0 +1,24 @@
|
||||
"""WebSocket server, connection registry, and frame routing.
|
||||
|
||||
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
|
||||
Uses the ``websockets`` core dep (v15): ``websockets.serve(handler, host,
|
||||
port, ssl=ctx)``.
|
||||
|
||||
Per-connection handler:
|
||||
1. Await first frame; must be ``hello {token, device_id, device_name,
|
||||
caps, fcm_token?}``. Verify token (constant-time) + allowlist. On
|
||||
failure: send ``error {code:"auth"}`` and close.
|
||||
2. On success: register in the connection registry (``device_id ->
|
||||
{ws, caps, fcm_token}``), send ``hello.ack {server_caps, sync_cursor,
|
||||
channels[]}``.
|
||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
||||
4. On close: deregister; if no devices remain, ensure pending outbox
|
||||
frames have push fired.
|
||||
|
||||
Routing: ``emit(chat_id, frame)`` broadcasts to ALL connected devices
|
||||
(single-user model). Heartbeat via WS ping/pong + app-level ping/pong.
|
||||
Backpressure: bounded per-connection send queue; coalesce ``message.update``
|
||||
under pressure, never drop ``message``/``tool.end``/``notification``.
|
||||
|
||||
Milestone M1.
|
||||
"""
|
||||
Reference in new issue
Block a user