feat(plugins): inject_message(origin=...) starts a gateway session in the plugin's own profile
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never comes back as an inbound message) had no way to get an agent turn there. `origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's session through the adapter's normal message path, in the profile whose plugin manager loaded the plugin, and the reply goes back to the origin chat: - Profile: resolved from the manager's immutable home, never an ambient one. The origin may not name another profile, the gateway's own routing for that chat through that profile's adapter must land in the plugin's profile, and the profile needs its own connected adapter for the platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line. - Authorization: same current-allowlist check as the session_key path (no bypass for host code). - Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the reply send after it inherit that profile's home and secrets (applies to the session_key path too, which used to send from the default loop context). - allow_gateway_injection is read from the plugin's own profile config. - Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and the "no home channel" nudge), which were posted into the plugin's fresh thread. Requested by canonicle-desk in #132609. Closes #132609
This commit is contained in:
@@ -1,12 +1,23 @@
|
||||
"""Plugin-triggered gateway turns (``PluginContext.inject_message``): the process-wide injector this
|
||||
runner publishes, the thread-safe scheduler, and the dispatch through the adapter message path."""
|
||||
runner publishes, the thread-safe scheduler, and the dispatch through the adapter message path.
|
||||
|
||||
Two targets: an existing session (``session_key``; route and session id pinned from the store) or a
|
||||
messaging chat (``origin``) whose session is created — or continued — through the normal session path
|
||||
in the calling plugin's OWN profile. The origin path never chooses a profile: the gateway's routing
|
||||
for that chat through that profile's adapter must already land in the plugin's profile, so a human
|
||||
follow-up in the same chat keys into the same session."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import logging
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from gateway.platforms.event import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
logger = logging.getLogger("gateway.run")
|
||||
|
||||
@@ -25,17 +36,29 @@ class GatewayPluginInjectionMixin:
|
||||
clear_published_gateway_message_host(self)
|
||||
|
||||
def _schedule_plugin_message_injection(
|
||||
self, *, session_key: str, content: str, plugin_id: str
|
||||
self, *, content: str, plugin_id: str, session_key: Optional[str] = None,
|
||||
origin: Optional[Mapping[str, Any]] = None, plugin_home: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""Schedule a plugin-triggered turn on the live gateway loop (thread-safe)."""
|
||||
"""Schedule a plugin-triggered turn on the live gateway loop (thread-safe).
|
||||
|
||||
With ``origin`` the profile, origin and adapter checks run here, synchronously, so the plugin
|
||||
gets ``False`` for a chat it may not start; authorization is rechecked at dispatch."""
|
||||
from gateway.run import safe_schedule_threadsafe
|
||||
loop = getattr(self, "_gateway_loop", None)
|
||||
if not getattr(self, "_running", False) or loop is None or loop.is_closed():
|
||||
return False
|
||||
|
||||
coro = self._dispatch_plugin_message_injection(
|
||||
session_key=session_key, content=content, plugin_id=plugin_id,
|
||||
)
|
||||
if origin is not None:
|
||||
source = self._plugin_origin_source(origin, plugin_home=plugin_home, plugin_id=plugin_id)
|
||||
if source is None:
|
||||
return False
|
||||
target = f"origin={source.platform.value}/{source.chat_id}"
|
||||
coro = self._dispatch_plugin_origin_injection(source=source, content=content, plugin_id=plugin_id)
|
||||
else:
|
||||
target = f"session={session_key}"
|
||||
coro = self._dispatch_plugin_message_injection(
|
||||
session_key=session_key, content=content, plugin_id=plugin_id,
|
||||
)
|
||||
try:
|
||||
current_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -62,63 +85,160 @@ class GatewayPluginInjectionMixin:
|
||||
try:
|
||||
if completed.result():
|
||||
return
|
||||
what, exc = "was not routed", None
|
||||
except (asyncio.CancelledError, concurrent.futures.CancelledError):
|
||||
return
|
||||
except Exception as err:
|
||||
what, exc = "failed", err
|
||||
logger.warning(
|
||||
"Plugin message injection %s: plugin=%s session=%s", what, plugin_id, session_key, exc_info=exc,
|
||||
)
|
||||
except Exception: # dispatch boundary: the plugin already got True; log, never raise
|
||||
logger.warning("Plugin message injection failed: plugin=%s %s", plugin_id, target, exc_info=True)
|
||||
return
|
||||
logger.warning("Plugin message injection was not routed: plugin=%s %s", plugin_id, target)
|
||||
|
||||
future.add_done_callback(_log_result)
|
||||
return True
|
||||
|
||||
def _plugin_injection_accepting(self) -> bool:
|
||||
return getattr(self, "_running", False) and not getattr(self, "_draining", False)
|
||||
|
||||
def _plugin_injection_scope(self, source: SessionSource):
|
||||
"""Multiplex: enter the source's profile runtime scope around ``handle_message`` so the
|
||||
background task it spawns — the turn AND the reply send after the handler returns — inherits
|
||||
that profile's home and secrets (this coroutine runs from the default loop context, not from
|
||||
the profile adapter's own intake task)."""
|
||||
if getattr(getattr(self, "config", None), "multiplex_profiles", False) is not True:
|
||||
return contextlib.nullcontext()
|
||||
from gateway.run import _async_profile_runtime_scope
|
||||
return _async_profile_runtime_scope(self._resolve_profile_home_for_source(source))
|
||||
|
||||
def _plugin_injection_authorized(self, source: SessionSource, *, plugin_id: str, target: str) -> bool:
|
||||
"""Current gateway authorization for the route the injected turn will run as. Host-side
|
||||
plugin code is trusted to ask, never to widen access: a stored or supplied route must pass
|
||||
today's allowlists / pairing / allow-all, and adapter-time grants (Discord roles) do not count."""
|
||||
try:
|
||||
authorized = self._is_user_authorized_for_source(source, allow_adapter_delegation=False)
|
||||
except Exception:
|
||||
logger.warning("Plugin message injection authorization check failed: plugin=%s %s",
|
||||
plugin_id, target, exc_info=True)
|
||||
return False
|
||||
if not authorized:
|
||||
logger.warning("Plugin message injection denied by current gateway authorization: plugin=%s %s",
|
||||
plugin_id, target)
|
||||
return authorized
|
||||
|
||||
async def _dispatch_plugin_message_injection(
|
||||
self, *, session_key: str, content: str, plugin_id: str
|
||||
) -> bool:
|
||||
"""Route a plugin-triggered turn through the session's live adapter."""
|
||||
def _accepting() -> bool:
|
||||
return getattr(self, "_running", False) and not getattr(self, "_draining", False)
|
||||
|
||||
if not _accepting():
|
||||
if not self._plugin_injection_accepting():
|
||||
return False
|
||||
entry = await self.async_session_store.lookup_by_session_key(session_key)
|
||||
if entry is None or entry.origin is None or not _accepting():
|
||||
if entry is None or entry.origin is None or not self._plugin_injection_accepting():
|
||||
return False
|
||||
|
||||
from gateway.session_identity import replace_source
|
||||
source = replace_source(self._restored_source(entry))
|
||||
try:
|
||||
authorized = self._is_user_authorized_for_source(source, allow_adapter_delegation=False)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Plugin message injection authorization check failed: plugin=%s session=%s",
|
||||
plugin_id, session_key, exc_info=True,
|
||||
)
|
||||
return False
|
||||
if not authorized:
|
||||
logger.warning(
|
||||
"Plugin message injection denied by current gateway authorization: "
|
||||
"plugin=%s session=%s", plugin_id, session_key,
|
||||
)
|
||||
if not self._plugin_injection_authorized(source, plugin_id=plugin_id, target=f"session={session_key}"):
|
||||
return False
|
||||
|
||||
adapter = self._delivery_adapter_for(source)
|
||||
if adapter is None:
|
||||
return False
|
||||
|
||||
await adapter.handle_message(MessageEvent(
|
||||
text=content, message_type=MessageType.TEXT, source=source, internal=True,
|
||||
allow_gateway_control=False,
|
||||
metadata={
|
||||
"hermes_plugin_id": plugin_id, "hermes_plugin_injection": True,
|
||||
"gateway_session_key": session_key, "gateway_session_id": entry.session_id,
|
||||
"gateway_session_strict": True,
|
||||
},
|
||||
))
|
||||
async with self._plugin_injection_scope(source):
|
||||
await adapter.handle_message(MessageEvent(
|
||||
text=content, message_type=MessageType.TEXT, source=source, internal=True,
|
||||
allow_gateway_control=False,
|
||||
metadata={
|
||||
"hermes_plugin_id": plugin_id, "hermes_plugin_injection": True,
|
||||
"gateway_session_key": session_key, "gateway_session_id": entry.session_id,
|
||||
"gateway_session_strict": True,
|
||||
},
|
||||
))
|
||||
logger.info(
|
||||
"Plugin message injection dispatched: plugin=%s session=%s session_id=%s",
|
||||
plugin_id, session_key, entry.session_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def _served_profile_for_home(self, home: Optional[Path]) -> Optional[str]:
|
||||
"""Name of the profile this gateway serves at *home* (a plugin manager's immutable home), or
|
||||
``None`` when this gateway does not serve it."""
|
||||
if home is None:
|
||||
return None
|
||||
from hermes_constants import get_routing_process_hermes_home, hermes_home_key
|
||||
key = hermes_home_key(home)
|
||||
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||||
from gateway.run import _multiplex_profile_homes
|
||||
try:
|
||||
served = _multiplex_profile_homes(self.config)
|
||||
except Exception:
|
||||
logger.warning("Plugin message injection: served-profile set unavailable", exc_info=True)
|
||||
return None
|
||||
return next((name for name, served_home in served if hermes_home_key(served_home) == key), None)
|
||||
if hermes_home_key(get_routing_process_hermes_home()) != key:
|
||||
return None
|
||||
return getattr(self, "_primary_profile_name", None) or self._active_profile_name()
|
||||
|
||||
def _plugin_origin_source(
|
||||
self, origin: Mapping[str, Any], *, plugin_home: Optional[Path], plugin_id: str,
|
||||
) -> Optional[SessionSource]:
|
||||
"""The canonicalized source for a plugin-supplied *origin*, or ``None`` (logged) when the
|
||||
plugin may not start a turn there: its profile is not served, the origin is malformed or
|
||||
names another profile, that profile has no live adapter for the platform, or the gateway's
|
||||
own routing for the chat lands in a different profile."""
|
||||
def _refuse(reason: str, *args: Any) -> None:
|
||||
logger.warning("Plugin message injection refused: plugin=%s " + reason, plugin_id, *args)
|
||||
|
||||
profile = self._served_profile_for_home(plugin_home)
|
||||
if profile is None:
|
||||
return _refuse("its profile home %s is not served by this gateway", plugin_home)
|
||||
fields = dict(origin)
|
||||
requested = fields.pop("profile", None)
|
||||
if requested and requested != profile:
|
||||
return _refuse("origin names profile %r; plugins may only start sessions in their own profile (%s)",
|
||||
requested, profile)
|
||||
try:
|
||||
source = SessionSource.from_dict(fields)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return _refuse("invalid origin (%s)", exc)
|
||||
where = f"{source.platform.value}/{source.chat_id}"
|
||||
adapter = self._adapters_for_profile(profile).get(source.platform)
|
||||
if adapter is None:
|
||||
return _refuse("no connected %s adapter for profile %s", source.platform.value, profile)
|
||||
# Same provenance build_source() stamps on a live inbound event from this adapter, so the
|
||||
# identity (receiving bot, runtime profile, authorization home) resolves exactly as it would
|
||||
# for a human message in this chat.
|
||||
source._transport_adapter_ref = weakref.ref(adapter)
|
||||
identity = self._canonicalize(source)
|
||||
if identity is None:
|
||||
return _refuse("origin %s does not resolve to a served profile", where)
|
||||
if identity.runtime_profile != profile:
|
||||
return _refuse("origin %s routes to profile %s, not the plugin's profile %s",
|
||||
where, identity.runtime_profile, profile)
|
||||
return source
|
||||
|
||||
async def _dispatch_plugin_origin_injection(
|
||||
self, *, source: SessionSource, content: str, plugin_id: str
|
||||
) -> bool:
|
||||
"""Start (or continue) the session at a plugin-supplied origin through the adapter's normal
|
||||
message path: ``get_or_create_session`` creates it, the reply goes back to that chat."""
|
||||
target = f"origin={source.platform.value}/{source.chat_id}"
|
||||
if not self._plugin_injection_accepting():
|
||||
return False
|
||||
adapter = self._delivery_adapter_for(source)
|
||||
if adapter is None:
|
||||
logger.warning("Plugin message injection: adapter for %s went away before dispatch", target)
|
||||
return False
|
||||
if not self._plugin_injection_authorized(source, plugin_id=plugin_id, target=target):
|
||||
return False
|
||||
session_key = self._session_key_for_source(source)
|
||||
async with self._plugin_injection_scope(source):
|
||||
await adapter.handle_message(MessageEvent(
|
||||
text=content, message_type=MessageType.TEXT, source=source, internal=True,
|
||||
allow_gateway_control=False,
|
||||
# Non-strict: the key is pinned against topic recovery, the session may not exist yet.
|
||||
metadata={
|
||||
"hermes_plugin_id": plugin_id, "hermes_plugin_injection": True,
|
||||
"gateway_session_key": session_key,
|
||||
},
|
||||
))
|
||||
logger.info("Plugin message injection dispatched: plugin=%s %s session=%s", plugin_id, target, session_key)
|
||||
return True
|
||||
|
||||
@@ -1409,12 +1409,12 @@ class GatewayTurnMixin:
|
||||
)
|
||||
return bounded
|
||||
|
||||
async def _hmwa_first_contact_notes(self, source, history, turn_sidecar_notes):
|
||||
async def _hmwa_first_contact_notes(self, source, history, turn_sidecar_notes, internal=False):
|
||||
"""First-ever-message onboarding note + one-time 'no home channel' prompt (both only when
|
||||
the session has no history). Delivered on the user message (sidecar), NOT the ephemeral
|
||||
system prompt: present-on-turn-1/absent-on-turn-2 was a guaranteed prompt diff + rebuild."""
|
||||
from gateway.run import _gateway_config_home, _home_target_env_var, _load_gateway_config
|
||||
if history:
|
||||
if history or internal: # internal = plugin/system turn: no human made first contact
|
||||
return
|
||||
human_platform = bool(source.platform) and source.platform not in (Platform.LOCAL, Platform.WEBHOOK)
|
||||
if human_platform and source.chat_type == "dm" and not await self.async_session_store.has_any_sessions():
|
||||
@@ -2101,7 +2101,7 @@ class GatewayTurnMixin:
|
||||
self._clear_session_env(_session_env_tokens)
|
||||
return t("gateway.errors.history_unavailable"), _session_env_tokens
|
||||
|
||||
await self._hmwa_first_contact_notes(source, history, turn_sidecar_notes)
|
||||
await self._hmwa_first_contact_notes(source, history, turn_sidecar_notes, internal=event.internal)
|
||||
|
||||
# Voice channel state rides the user message ONLY when changed (in the system prompt it
|
||||
# forced a rebuild + prompt-cache re-key per message).
|
||||
|
||||
@@ -602,59 +602,29 @@ class PluginContext:
|
||||
# manager's home, never the active profile's (#65593 constraint).
|
||||
def inject_message(
|
||||
self, content: str, role: str = "user", *, session_key: str | None = None,
|
||||
origin: Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Inject a message into a CLI, Ink TUI/desktop, or messaging-gateway conversation.
|
||||
|
||||
CLI uses the attached REPL queues. Ink TUI and desktop use a separate injector
|
||||
from the messaging gateway and queue onto the live session named by ``session_key``
|
||||
(the durable key, not the ephemeral UI session id). Non-CLI injection needs that
|
||||
``session_key`` plus ``plugins.entries.<plugin_id>.allow_gateway_injection``.
|
||||
(the durable key, not the ephemeral UI session id). ``origin`` (a
|
||||
``SessionSource.to_dict()``-shaped mapping: platform, chat_id, chat_type, thread_id,
|
||||
user_id, ...) instead names a messaging chat: the gateway starts (or continues) that
|
||||
chat's session in THIS plugin's own profile and runs a turn there; the plugin cannot
|
||||
target another profile. Non-CLI injection needs ``session_key`` or ``origin`` plus
|
||||
``plugins.entries.<plugin_id>.allow_gateway_injection`` in the plugin's profile config.
|
||||
``True`` means a host accepted the request, not that the turn completed.
|
||||
"""
|
||||
cli = self._manager._cli_ref
|
||||
msg = content if role == "user" else f"[{role}] {content}"
|
||||
if cli is not None:
|
||||
queue_ = cli._interrupt_queue if getattr(cli, "_agent_running", False) else cli._pending_input
|
||||
queue_.put(msg)
|
||||
return True
|
||||
if not session_key:
|
||||
logger.warning("inject_message: gateway mode requires an existing session_key")
|
||||
return False
|
||||
if not self._gateway_injection_allowed():
|
||||
logger.warning("inject_message: gateway injection denied for plugin %s; set "
|
||||
"plugins.entries.%s.allow_gateway_injection: true to allow it",
|
||||
self.plugin_id, self.plugin_id)
|
||||
return False
|
||||
# TUI/desktop host is a different slot. It accepts only when it owns this
|
||||
# session_key; a miss falls through so a co-resident messaging gateway
|
||||
# still receives its own keys. An exception fails closed — do not also
|
||||
# hand the same text to the gateway.
|
||||
if self._manager.has_tui_message_injector:
|
||||
try:
|
||||
if self._manager.inject_tui_message(
|
||||
session_key=session_key, content=msg, plugin_id=self.plugin_id,
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("inject_message: TUI scheduling failed for plugin %s", self.plugin_id,
|
||||
exc_info=True)
|
||||
return False
|
||||
if not self._manager.has_gateway_message_injector:
|
||||
logger.warning("inject_message: no live gateway is available")
|
||||
return False
|
||||
try:
|
||||
return bool(self._manager.inject_gateway_message(
|
||||
session_key=session_key, content=msg, plugin_id=self.plugin_id,
|
||||
))
|
||||
except Exception:
|
||||
logger.warning("inject_message: gateway scheduling failed for plugin %s", self.plugin_id,
|
||||
exc_info=True)
|
||||
return False
|
||||
from hermes_cli.plugins_injection import inject_plugin_message
|
||||
return inject_plugin_message(self, content, role, session_key=session_key, origin=origin)
|
||||
|
||||
def _gateway_injection_allowed(self) -> bool:
|
||||
"""Return whether this plugin may trigger gateway session turns."""
|
||||
"""Return whether this plugin may trigger gateway session turns (read from the plugin's
|
||||
own profile config, never the calling thread's ambient home)."""
|
||||
try:
|
||||
cfg = load_config_readonly() or {}
|
||||
with _plugin_home_scope(self._manager.home_path):
|
||||
cfg = load_config_readonly() or {}
|
||||
except Exception:
|
||||
return False
|
||||
return (_plugin_settings_entry(cfg, self.plugin_id) or {}).get("allow_gateway_injection") is True
|
||||
|
||||
62
hermes_cli/plugins_injection.py
Normal file
62
hermes_cli/plugins_injection.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Host routing for ``PluginContext.inject_message``: classic CLI REPL, Ink TUI / desktop, messaging
|
||||
gateway (existing ``session_key`` or a new session at ``origin``)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("hermes_cli.plugins")
|
||||
|
||||
|
||||
def inject_plugin_message(
|
||||
ctx: Any, content: str, role: str, *, session_key: Optional[str],
|
||||
origin: Optional[Mapping[str, Any]],
|
||||
) -> bool:
|
||||
manager = ctx._manager
|
||||
msg = content if role == "user" else f"[{role}] {content}"
|
||||
cli = manager._cli_ref
|
||||
# An origin names a messaging chat; the local REPL is never that chat.
|
||||
if cli is not None and origin is None:
|
||||
queue_ = cli._interrupt_queue if getattr(cli, "_agent_running", False) else cli._pending_input
|
||||
queue_.put(msg)
|
||||
return True
|
||||
if session_key and origin is not None:
|
||||
logger.warning("inject_message: pass session_key or origin, not both")
|
||||
return False
|
||||
if not session_key and origin is None:
|
||||
logger.warning("inject_message: gateway mode requires an existing session_key or an origin")
|
||||
return False
|
||||
if origin is not None and not isinstance(origin, Mapping):
|
||||
logger.warning("inject_message: origin must be a mapping in the SessionSource.to_dict() shape")
|
||||
return False
|
||||
if not ctx._gateway_injection_allowed():
|
||||
logger.warning("inject_message: gateway injection denied for plugin %s; set "
|
||||
"plugins.entries.%s.allow_gateway_injection: true to allow it",
|
||||
ctx.plugin_id, ctx.plugin_id)
|
||||
return False
|
||||
# TUI/desktop host is a different slot. It accepts only when it owns this
|
||||
# session_key; a miss falls through so a co-resident messaging gateway
|
||||
# still receives its own keys. An exception fails closed — do not also
|
||||
# hand the same text to the gateway.
|
||||
if session_key and manager.has_tui_message_injector:
|
||||
try:
|
||||
if manager.inject_tui_message(session_key=session_key, content=msg, plugin_id=ctx.plugin_id):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("inject_message: TUI scheduling failed for plugin %s", ctx.plugin_id, exc_info=True)
|
||||
return False
|
||||
if not manager.has_gateway_message_injector:
|
||||
logger.warning("inject_message: no live gateway is available")
|
||||
return False
|
||||
if origin is None:
|
||||
target: dict[str, Any] = {"session_key": session_key}
|
||||
else:
|
||||
# The manager's immutable home is the plugin's profile: the gateway creates the session
|
||||
# there or nowhere (never the ambient HERMES_HOME of whatever thread called us).
|
||||
target = {"origin": dict(origin), "plugin_home": manager.home_path}
|
||||
try:
|
||||
return bool(manager.inject_gateway_message(**target, content=msg, plugin_id=ctx.plugin_id))
|
||||
except Exception:
|
||||
logger.warning("inject_message: gateway scheduling failed for plugin %s", ctx.plugin_id, exc_info=True)
|
||||
return False
|
||||
200
tests/gateway/test_plugin_origin_injection.py
Normal file
200
tests/gateway/test_plugin_origin_injection.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""``ctx.inject_message(origin=...)`` starts a gateway session in the plugin's OWN profile (#132609).
|
||||
|
||||
Real ``GatewayRunner`` + real ``BasePlatformAdapter`` ingress with a recording transport; only the
|
||||
model call (``_run_agent_inner``) is replaced, and it records the home and secret scope the turn
|
||||
actually ran under.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import hermes_yaml as yaml
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||||
from gateway.profile_routing import parse_profile_routes
|
||||
from gateway.run import GatewayRunner
|
||||
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||
|
||||
PLUGIN = "desk"
|
||||
|
||||
|
||||
class _RecordingAdapter(BasePlatformAdapter):
|
||||
def __init__(self, label: str):
|
||||
super().__init__(PlatformConfig(enabled=True, token=f"tok-{label}"), Platform.TELEGRAM)
|
||||
self.label = label
|
||||
self.sent: list[tuple[str, str]] = []
|
||||
self.send_scopes: list[tuple] = [] # (home, PROBE_MARKER) each send ran under
|
||||
self._running = True
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._mark_disconnected()
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
from agent.secret_scope import get_secret
|
||||
from hermes_constants import get_hermes_home
|
||||
self.sent.append((str(chat_id), content))
|
||||
self.send_scopes.append((get_hermes_home(), get_secret("PROBE_MARKER")))
|
||||
return SendResult(success=True, message_id=str(len(self.sent)))
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id, "type": "group"}
|
||||
|
||||
|
||||
def _write_home(home, marker: str) -> None:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"plugins": {"entries": {PLUGIN: {"allow_gateway_injection": True}}},
|
||||
}), encoding="utf-8")
|
||||
(home / ".env").write_text(f"TELEGRAM_ALLOWED_USERS=u1\nPROBE_MARKER={marker}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _context(home) -> tuple[PluginContext, PluginManager]:
|
||||
manager = PluginManager(scope_key=str(home))
|
||||
return PluginContext(PluginManifest(name=PLUGIN, key=PLUGIN, source="user"), manager), manager
|
||||
|
||||
|
||||
def _origin(chat_id: str, **extra) -> dict:
|
||||
return {"platform": "telegram", "chat_id": chat_id, "chat_type": "group", "thread_id": "7",
|
||||
"user_id": "u1", "user_name": "Requester", **extra}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway(tmp_path, monkeypatch):
|
||||
"""Multiplexed gateway serving ``default`` (bot P), ``alpha`` (its own bot A) and ``beta`` (its
|
||||
own bot B); a route sends chat 999 on bot A to ``beta``."""
|
||||
home = tmp_path / "hh"
|
||||
homes = {"default": home, "alpha": home / "profiles" / "alpha", "beta": home / "profiles" / "beta"}
|
||||
for name, profile_home in homes.items():
|
||||
_write_home(profile_home, marker=name)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", home)
|
||||
|
||||
config = GatewayConfig(multiplex_profiles=True, sessions_dir=home / "sessions")
|
||||
config.profile_routes = parse_profile_routes([
|
||||
{"name": "beta-on-a", "platform": "telegram", "profile": "beta", "chat_id": "999", "bot_profile": "alpha"},
|
||||
])
|
||||
runner = GatewayRunner(config)
|
||||
adapters = {name: _RecordingAdapter(name) for name in homes}
|
||||
runner.adapters = {Platform.TELEGRAM: adapters["default"]}
|
||||
runner._profile_adapters = {name: {Platform.TELEGRAM: adapters[name]} for name in ("alpha", "beta")}
|
||||
for name, adapter in adapters.items():
|
||||
adapter.gateway_runner = runner
|
||||
if name != "default":
|
||||
adapter.set_owner_profile(name)
|
||||
adapter.set_message_handler(runner._make_profile_message_handler(name))
|
||||
else:
|
||||
adapter.set_message_handler(runner._handle_message)
|
||||
|
||||
turns = []
|
||||
|
||||
async def _fake_turn(message, context_prompt, history, source, session_id, **_kwargs):
|
||||
from agent.secret_scope import get_secret
|
||||
from hermes_constants import get_hermes_home
|
||||
turns.append(SimpleNamespace(message=message, history=list(history), session_id=session_id,
|
||||
home=get_hermes_home(), marker=get_secret("PROBE_MARKER")))
|
||||
return {"final_response": f"reply-{len(turns)}", "messages": [], "tools": [],
|
||||
"history_offset": len(history), "last_prompt_tokens": 0}
|
||||
|
||||
runner._run_agent_inner = _fake_turn
|
||||
served = list(homes.items())
|
||||
with patch("hermes_cli.profiles.profiles_to_serve", return_value=served), \
|
||||
patch("hermes_cli.profiles.get_profile_dir", side_effect=lambda n: homes[n]), \
|
||||
patch("hermes_cli.profiles.profile_exists", side_effect=lambda n: n in homes):
|
||||
yield SimpleNamespace(runner=runner, homes=homes, adapters=adapters, turns=turns)
|
||||
|
||||
|
||||
async def _publish(runner):
|
||||
runner._gateway_loop = asyncio.get_running_loop()
|
||||
runner._running = True
|
||||
runner._install_plugin_message_injector()
|
||||
|
||||
|
||||
async def _settle(runner, adapter, rounds: int = 200):
|
||||
for _ in range(rounds):
|
||||
await asyncio.sleep(0.01)
|
||||
if not runner._background_tasks and not adapter._session_tasks and not adapter._active_sessions:
|
||||
return
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_creates_session_in_own_profile_and_replies_to_origin(gateway):
|
||||
"""No session at the origin: the plugin's profile gets a fresh session, the turn runs under that
|
||||
profile's home and secrets, the reply goes back to the origin chat, and a human follow-up in the
|
||||
same chat continues the SAME session (A -> B -> A: beta in between, alpha's scope comes back)."""
|
||||
runner, alpha, beta = gateway.runner, gateway.adapters["alpha"], gateway.adapters["beta"]
|
||||
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
|
||||
ctx_beta, mgr_beta = _context(gateway.homes["beta"])
|
||||
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha, mgr_beta]):
|
||||
await _publish(runner)
|
||||
assert ctx_alpha.inject_message("Kick off the review", origin=_origin("100")) is True
|
||||
await _settle(runner, alpha)
|
||||
assert ctx_beta.inject_message("Beta's own post", origin=_origin("200")) is True
|
||||
await _settle(runner, beta)
|
||||
assert ctx_alpha.inject_message("Second post", origin=_origin("101")) is True
|
||||
await _settle(runner, alpha)
|
||||
|
||||
first, middle, last = gateway.turns
|
||||
assert (first.home, first.marker) == (gateway.homes["alpha"], "alpha")
|
||||
assert (middle.home, middle.marker) == (gateway.homes["beta"], "beta")
|
||||
assert (last.home, last.marker) == (gateway.homes["alpha"], "alpha")
|
||||
assert first.message.endswith("Kick off the review") and first.history == []
|
||||
assert alpha.sent == [("100", "reply-1"), ("101", "reply-3")]
|
||||
assert beta.sent == [("200", "reply-2")]
|
||||
# The reply is sent after the handler returns: it must still run in the plugin's profile scope.
|
||||
assert alpha.send_scopes == [(gateway.homes["alpha"], "alpha")] * 2
|
||||
assert beta.send_scopes == [(gateway.homes["beta"], "beta")]
|
||||
assert gateway.adapters["default"].sent == []
|
||||
|
||||
entry = runner.session_store._entries["agent:alpha:telegram:group:100:7"]
|
||||
assert entry.session_id == first.session_id
|
||||
assert not any(key.startswith(("agent:main:", "agent:beta:")) and ":100:" in key
|
||||
for key in runner.session_store._entries)
|
||||
|
||||
# A human reply in the same chat keys into the plugin-created session. A secondary adapter's
|
||||
# intake task runs inside its profile scope (it is spawned from connect() under that scope).
|
||||
reply = alpha.build_source(chat_id="100", chat_type="group", thread_id="7", user_id="u1")
|
||||
async with gateway_run._async_profile_runtime_scope(gateway.homes["alpha"]):
|
||||
await alpha.handle_message(MessageEvent(text="thanks", message_type=MessageType.TEXT, source=reply))
|
||||
await _settle(runner, alpha)
|
||||
assert gateway.turns[-1].session_id == first.session_id
|
||||
assert alpha.sent[-1] == ("100", "reply-4")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_cannot_reach_another_profile(gateway):
|
||||
"""A plugin in ``alpha`` cannot start a session in ``beta``: not by naming the profile, not
|
||||
through a chat the gateway routes to ``beta``. Refusal is synchronous and nothing is created."""
|
||||
runner, alpha = gateway.runner, gateway.adapters["alpha"]
|
||||
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
|
||||
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha]):
|
||||
await _publish(runner)
|
||||
assert ctx_alpha.inject_message("x", origin=_origin("100", profile="beta")) is False
|
||||
assert ctx_alpha.inject_message("x", origin=_origin("999")) is False # routed to beta
|
||||
assert ctx_alpha.inject_message("x", origin={"platform": "telegram"}) is False # malformed
|
||||
await _settle(runner, alpha)
|
||||
|
||||
assert gateway.turns == [] and alpha.sent == [] and gateway.adapters["beta"].sent == []
|
||||
assert runner.session_store._entries == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_without_connected_adapter_returns_false(gateway):
|
||||
runner = gateway.runner
|
||||
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
|
||||
runner._profile_adapters["alpha"] = {} # alpha's bot is down; never borrow default's
|
||||
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha]):
|
||||
await _publish(runner)
|
||||
assert ctx_alpha.inject_message("x", origin=_origin("100")) is False
|
||||
assert ctx_alpha.inject_message("x", origin={**_origin("100"), "platform": "discord"}) is False
|
||||
assert runner._background_tasks == set()
|
||||
assert gateway.adapters["default"].sent == []
|
||||
@@ -252,3 +252,67 @@ def test_published_gateway_host_reaches_existing_and_future_profile_managers(tmp
|
||||
plugins_mod.clear_published_gateway_message_host(owner)
|
||||
plugins_mod._reset_plugin_managers_for_tests()
|
||||
secret_scope.set_multiplex_active(was_active)
|
||||
|
||||
|
||||
ORIGIN = {"platform": "discord", "chat_id": "42", "chat_type": "thread", "thread_id": "42", "user_id": "u1"}
|
||||
|
||||
|
||||
def test_origin_injection_targets_the_plugins_own_home(tmp_path, monkeypatch):
|
||||
"""``origin`` is forwarded with the manager's immutable home — never an ambient one — so the
|
||||
gateway can only start the session in the profile that loaded the plugin."""
|
||||
_write_plugin_config(tmp_path, monkeypatch, {"allow_gateway_injection": True})
|
||||
context, manager = _context()
|
||||
injector = MagicMock(return_value=True)
|
||||
manager.set_gateway_message_injector(object(), injector)
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
token = set_hermes_home_override(other)
|
||||
try:
|
||||
assert context.inject_message("Kick off", origin=ORIGIN) is True
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
injector.assert_called_once_with(
|
||||
origin=ORIGIN, plugin_home=manager.home_path, content="Kick off", plugin_id="notify-plugin",
|
||||
)
|
||||
|
||||
|
||||
def test_origin_injection_permission_comes_from_the_plugins_own_profile(tmp_path, monkeypatch):
|
||||
"""The allow_gateway_injection gate reads the plugin's profile config even when the calling
|
||||
thread's ambient home is another profile that grants it."""
|
||||
_write_plugin_config(tmp_path, monkeypatch, {"allow_gateway_injection": False})
|
||||
context, manager = _context()
|
||||
injector = MagicMock(return_value=True)
|
||||
manager.set_gateway_message_injector(object(), injector)
|
||||
granting = tmp_path / "granting"
|
||||
granting.mkdir()
|
||||
(granting / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"entries": {"notify-plugin": {"allow_gateway_injection": True}}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
token = set_hermes_home_override(granting)
|
||||
try:
|
||||
assert context.inject_message("x", origin=ORIGIN) is False
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
injector.assert_not_called()
|
||||
|
||||
|
||||
def test_origin_injection_rejects_ambiguous_or_malformed_targets(tmp_path, monkeypatch):
|
||||
_write_plugin_config(tmp_path, monkeypatch, {"allow_gateway_injection": True})
|
||||
context, manager = _context()
|
||||
injector = MagicMock(return_value=True)
|
||||
manager.set_gateway_message_injector(object(), injector)
|
||||
|
||||
assert context.inject_message("x", session_key="agent:main:discord:thread:42", origin=ORIGIN) is False
|
||||
assert context.inject_message("x", origin="discord:42") is False
|
||||
injector.assert_not_called()
|
||||
|
||||
|
||||
def test_origin_injection_never_lands_in_the_attached_cli():
|
||||
"""An origin names a messaging chat; a CLI-hosted plugin gets False, not a REPL queue entry."""
|
||||
context, manager = _context()
|
||||
cli = SimpleNamespace(_agent_running=False, _pending_input=SimpleQueue(), _interrupt_queue=SimpleQueue())
|
||||
manager._cli_ref = cli
|
||||
|
||||
assert context.inject_message("x", origin=ORIGIN) is False
|
||||
assert cli._pending_input.empty() and cli._interrupt_queue.empty()
|
||||
|
||||
@@ -103,7 +103,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function.
|
||||
| Add slash commands | `ctx.register_command(name, handler, description)` — adds `/name` in CLI and gateway sessions |
|
||||
| Dispatch tools from commands | `ctx.dispatch_tool(name, args)` — invokes a registered tool with parent-agent context auto-wired |
|
||||
| Add CLI commands | `ctx.register_cli_command(name, help, setup_fn, handler_fn)` — adds `hermes <plugin> <subcommand>` |
|
||||
| Inject messages | `ctx.inject_message(content, role="user", session_key=...)` - see [Injecting Messages](#injecting-messages) |
|
||||
| Inject messages | `ctx.inject_message(content, role="user", session_key=... \| origin=...)` - see [Injecting Messages](#injecting-messages) |
|
||||
| Ship data files | `Path(__file__).parent / "data" / "file.yaml"` |
|
||||
| Bundle skills | `ctx.register_skill(name, path)` — namespaced as `plugin:skill`, loaded via `skill_view("plugin:skill")` |
|
||||
| Gate on env vars | `requires_env: [API_KEY]` in plugin.yaml — prompted during `hermes plugins install` |
|
||||
@@ -892,7 +892,7 @@ In a running session, `/plugins` shows which plugins are currently loaded.
|
||||
|
||||
## Injecting Messages
|
||||
|
||||
Plugins can inject messages into a CLI conversation or a known gateway session using `ctx.inject_message()`:
|
||||
Plugins can inject messages into a CLI conversation, a known gateway session, or a new gateway session in a given chat using `ctx.inject_message()`:
|
||||
|
||||
```python
|
||||
# Active CLI conversation
|
||||
@@ -904,9 +904,22 @@ ctx.inject_message(
|
||||
role="user",
|
||||
session_key="agent:main:telegram:dm:123456789",
|
||||
)
|
||||
|
||||
# Start (or continue) the session for a chat/thread, e.g. a Discord forum post the plugin just opened
|
||||
ctx.inject_message(
|
||||
"Review the attached spec and post a plan here.",
|
||||
origin={
|
||||
"platform": "discord",
|
||||
"chat_id": "1290000000000000000", # the forum post (thread) id
|
||||
"chat_type": "thread",
|
||||
"thread_id": "1290000000000000000",
|
||||
"user_id": "123456789012345678", # who the turn runs as; must pass the gateway's allowlist
|
||||
"user_name": "Desk Orchestrator",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Signature:** `ctx.inject_message(content: str, role: str = "user", *, session_key: str | None = None) -> bool`
|
||||
**Signature:** `ctx.inject_message(content: str, role: str = "user", *, session_key: str | None = None, origin: Mapping[str, Any] | None = None) -> bool`
|
||||
|
||||
In CLI mode:
|
||||
|
||||
@@ -918,14 +931,22 @@ In CLI mode:
|
||||
In gateway mode:
|
||||
|
||||
- `session_key` is required and must identify an existing gateway session. It is the stable routing key, not the CLI session ID.
|
||||
- Hermes reuses that session's stored platform, chat, thread, profile, and conversation history. Plugins cannot supply a new chat route through this API.
|
||||
- Hermes reuses that session's stored platform, chat, thread, profile, and conversation history. Plugins cannot change that route through `session_key`.
|
||||
- Hermes rechecks the stored route against the gateway's current authorisation rules before dispatch.
|
||||
- Routes that relied only on an adapter-time or upstream authorisation decision are rejected unless Hermes can revalidate them from current core allowlists, pairing, or explicit allow-all configuration.
|
||||
- Injected text is always conversational input. It cannot invoke slash commands, approve tools, or resolve pending confirmation and clarification prompts.
|
||||
- The route and conversation are pinned while dispatch is pending. Hermes drops the request if topic recovery changes the route or the session rotates before handling starts.
|
||||
- The request enters the platform adapter's normal message path. Active sessions use the existing busy-session queue rather than starting a competing turn.
|
||||
- Returns `True` when the live gateway accepts the request for asynchronous dispatch. This does not confirm that the agent turn or platform delivery has completed.
|
||||
- Returns `False` when `session_key` is omitted, the permission is not granted, or no live host can accept the request. Unknown or unroutable session keys discovered after asynchronous acceptance are written to the gateway log.
|
||||
- Returns `False` when neither `session_key` nor `origin` is given (or both are), the permission is not granted, or no live host can accept the request. Unknown or unroutable session keys discovered after asynchronous acceptance are written to the gateway log.
|
||||
|
||||
Starting a session with `origin` (gateway mode only):
|
||||
|
||||
- `origin` uses the same shape as a stored session origin (`SessionSource.to_dict()`): `platform` and `chat_id` are required; `chat_type`, `thread_id`, `user_id`, `user_name`, `chat_name` and the other origin fields are optional. Use it when no session exists yet — for example, a bot-authored forum post, which platforms like Discord never deliver back to the bot as an inbound message.
|
||||
- The session is created in **the plugin's own profile** (the profile whose plugin manager loaded it) through the normal session path, with a fresh prompt and an empty history, and the reply is delivered to that chat/thread. If the chat already has a session in that profile, the text joins it exactly like a human message would (the busy-session queue applies), so a human follow-up in the same thread continues the same session.
|
||||
- A plugin can never target another profile. `False` is returned (with the reason in the gateway log) when the origin names a different `profile`, when the gateway's own profile routing sends that chat to a different profile, when the plugin's profile is not served by this gateway, or when that profile has no connected adapter for `platform` — another profile's bot is never borrowed. On a multiplexed gateway the turn runs under the plugin's profile home and secrets.
|
||||
- Authorisation is not bypassed: the origin's user must pass the gateway's current allowlists, pairing, or allow-all configuration for that profile, the same check as the `session_key` path. Choose a `user_id` that is authorised, or the request is dropped at dispatch and logged.
|
||||
- The same text rules apply: no slash commands, approvals, or prompt resolution. The permission below is read from the plugin's own profile `config.yaml`.
|
||||
|
||||
Ink TUI (`hermes --tui`) and the desktop / dashboard chat are a third host. They do not set the classic CLI reference and they do not register on the messaging-gateway injector — those two hosts stay separate so a live gateway cannot clobber the TUI (or the reverse). Pass the session's durable `session_key` (the `ses_…` id), not the ephemeral UI session id. Hermes queues the text on that session's prompt queue: a busy session keeps the message for the next turn, an idle session starts one. A key that is not a live TUI session is left for the messaging gateway when one is running, and is never rerouted to a different chat.
|
||||
|
||||
@@ -941,7 +962,7 @@ plugins:
|
||||
```
|
||||
|
||||
:::warning
|
||||
Only grant gateway injection to plugins you trust. Hermes checks this host API permission and restricts it to existing session routes, but Python plugins run in-process and this setting is not a sandbox.
|
||||
Only grant gateway injection to plugins you trust. Hermes checks this host API permission, restricts it to existing session routes or new sessions in the plugin's own profile, and applies current authorisation, but Python plugins run in-process and this setting is not a sandbox.
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Reference in New Issue
Block a user