Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -127,7 +127,11 @@ class MemoryStore:
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create tables, indexes, and triggers if they do not exist. Enable WAL mode."""
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
# Use the shared WAL-fallback helper so memory_store.db degrades
|
||||
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same issue as
|
||||
# state.db / kanban.db — see hermes_state._WAL_INCOMPAT_MARKERS).
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
# Migrate: add hrr_vector column if missing (safe for existing databases)
|
||||
columns = {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}
|
||||
|
||||
@@ -100,18 +100,19 @@ class _VikingClient:
|
||||
raise ImportError("httpx is required for OpenViking: pip install httpx")
|
||||
|
||||
def _headers(self) -> dict:
|
||||
# Only send tenant headers when the user actually configured them.
|
||||
# Legacy installs had account/user defaulted to the literal string
|
||||
# "default" — treat that as unset so authenticated remote servers
|
||||
# that derive tenancy from the Bearer key aren't overridden by a
|
||||
# bogus tenant value.
|
||||
# Always send tenant headers when account/user are configured.
|
||||
# OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User
|
||||
# for ROOT API key requests to tenant-scoped APIs — omitting them
|
||||
# causes INVALID_ARGUMENT errors even when account="default".
|
||||
# User-level keys can omit them (server derives tenancy from the key),
|
||||
# but ROOT keys must always include them explicitly.
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-OpenViking-Agent": self._agent,
|
||||
}
|
||||
if self._account and self._account != "default":
|
||||
if self._account:
|
||||
h["X-OpenViking-Account"] = self._account
|
||||
if self._user and self._user != "default":
|
||||
if self._user:
|
||||
h["X-OpenViking-User"] = self._user
|
||||
if self._api_key:
|
||||
h["X-API-Key"] = self._api_key
|
||||
|
||||
@@ -1010,13 +1010,30 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
||||
+ (sender_email or "unknown").replace("@", "_at_").replace(".", "_")
|
||||
)
|
||||
text = envelope.get("text", "") or ""
|
||||
# Honor the relay's declared sender_type when present so the
|
||||
# downstream BOT self-filter (sender_type == "BOT") fires for
|
||||
# bot-originated messages forwarded by the relay. Hardcoding
|
||||
# "HUMAN" here meant the bot would re-process its own replies
|
||||
# if the relay forwarded them, and allowed a relay envelope to
|
||||
# impersonate any allowlisted user without ever being marked
|
||||
# as a bot. Default to "HUMAN" for backward compatibility when
|
||||
# the relay does not provide the field.
|
||||
#
|
||||
# Operator contract: the relay MUST forward sender.type from
|
||||
# the upstream Chat event as ``sender_type``. Relays that
|
||||
# forward bot replies as HUMAN (or omit the field) cannot be
|
||||
# distinguished from genuine humans here.
|
||||
sender_type_raw = (envelope.get("sender_type") or "HUMAN")
|
||||
sender_type = str(sender_type_raw).strip().upper() or "HUMAN"
|
||||
if sender_type not in {"HUMAN", "BOT"}:
|
||||
sender_type = "HUMAN"
|
||||
msg: Dict[str, Any] = {
|
||||
"name": envelope.get("message_name", "") or "",
|
||||
"sender": {
|
||||
"name": sender_name_surrogate,
|
||||
"email": sender_email,
|
||||
"displayName": sender_display,
|
||||
"type": "HUMAN",
|
||||
"type": sender_type,
|
||||
},
|
||||
"text": text,
|
||||
"argumentText": text,
|
||||
@@ -3019,6 +3036,165 @@ def interactive_setup() -> None:
|
||||
print_info("Restart the gateway: hermes gateway restart")
|
||||
|
||||
|
||||
# Strict resource-name pattern. ``spaces/<id>`` and ``users/<id>`` must
|
||||
# only contain Google Chat's documented character set; anything else
|
||||
# means a tampered chat_id trying to break out of the REST URL path
|
||||
# (path traversal, ``?`` query injection, ``#`` fragment truncation).
|
||||
_GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
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]:
|
||||
"""POST a single Google Chat message via the REST API without the SDK.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (e.g. ``hermes cron`` running as a
|
||||
separate process from ``hermes gateway``). Without this hook,
|
||||
``deliver=google_chat`` cron jobs fail with ``No live adapter for
|
||||
platform``.
|
||||
|
||||
Configuration: requires service-account credentials via
|
||||
``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``,
|
||||
or Application Default Credentials, and a space resource name as
|
||||
``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/<id>``).
|
||||
|
||||
Security: ``chat_id`` is validated against the documented Google Chat
|
||||
resource-name character set before substitution into the REST URL so
|
||||
a tampered value cannot path-traverse or query-inject.
|
||||
|
||||
``media_files`` and ``force_document`` are accepted for signature
|
||||
parity but are not implemented for the standalone path; messages with
|
||||
attachments send as text-only. The live adapter handles attachments.
|
||||
"""
|
||||
if not chat_id:
|
||||
return {"error": "Google Chat standalone send: chat_id (space resource) is required"}
|
||||
if not _GCHAT_CHAT_ID_RE.match(chat_id):
|
||||
return {"error": (
|
||||
f"Google Chat standalone send: chat_id {chat_id!r} must match "
|
||||
f"'spaces/<id>' or 'users/<id>' with only [A-Za-z0-9_-] in the id"
|
||||
)}
|
||||
if thread_id is not None and not re.match(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$", thread_id):
|
||||
return {"error": (
|
||||
f"Google Chat standalone send: thread_id {thread_id!r} must match "
|
||||
f"'spaces/<id>/threads/<id>'"
|
||||
)}
|
||||
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
sa_value = (
|
||||
extra.get("service_account_json")
|
||||
or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
|
||||
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
)
|
||||
|
||||
if service_account is None:
|
||||
return {"error": "Google Chat standalone send: google-auth not installed"}
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request as _GoogleAuthRequest
|
||||
except Exception as e:
|
||||
return {"error": f"Google Chat standalone send: google-auth import failed: {e}"}
|
||||
|
||||
try:
|
||||
if sa_value:
|
||||
stripped = sa_value.lstrip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
info = json.loads(sa_value)
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"error": f"Google Chat standalone send: inline SA JSON is invalid: {exc}"}
|
||||
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
|
||||
else:
|
||||
if not os.path.exists(sa_value):
|
||||
return {"error": f"Google Chat standalone send: SA JSON file not found at {sa_value}"}
|
||||
try:
|
||||
with open(sa_value, "r", encoding="utf-8") as fh:
|
||||
info = json.load(fh)
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"error": f"Google Chat standalone send: SA JSON file is invalid: {exc}"}
|
||||
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
|
||||
else:
|
||||
try:
|
||||
import google.auth as _google_auth
|
||||
except ImportError:
|
||||
return {"error": (
|
||||
"Google Chat standalone send: no SA credentials configured "
|
||||
"and google-auth is not installed for ADC fallback"
|
||||
)}
|
||||
try:
|
||||
creds, _project = _google_auth.default(scopes=_CHAT_SCOPES)
|
||||
except Exception as exc:
|
||||
return {"error": (
|
||||
f"Google Chat standalone send: no SA credentials configured "
|
||||
f"and Application Default Credentials are unavailable: {exc}"
|
||||
)}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"Google Chat standalone send: credential load failed: {e}"}
|
||||
|
||||
# Bound the synchronous urllib3-backed token refresh so a hung Google
|
||||
# STS endpoint cannot stall the cron scheduler indefinitely.
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(creds.refresh, _GoogleAuthRequest()),
|
||||
timeout=10.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Google Chat standalone send: token refresh timed out"}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"Google Chat standalone send: token refresh failed: {e}"}
|
||||
|
||||
token = getattr(creds, "token", None)
|
||||
if not token:
|
||||
return {"error": "Google Chat standalone send: refreshed credentials have no token"}
|
||||
|
||||
body: Dict[str, Any] = {"text": message}
|
||||
if thread_id:
|
||||
body["thread"] = {"name": thread_id}
|
||||
|
||||
url = f"https://chat.googleapis.com/v1/{chat_id}/messages"
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
return {"error": "Google Chat standalone send: aiohttp not installed"}
|
||||
|
||||
try:
|
||||
async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
text = await resp.text()
|
||||
return {"error": (
|
||||
f"Google Chat standalone send: API returned "
|
||||
f"{resp.status}: {text[:300]}"
|
||||
)}
|
||||
payload = await resp.json()
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": payload.get("name"),
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Google Chat standalone send raised", exc_info=True)
|
||||
return {"error": f"Google Chat standalone send failed: {e}"}
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system at startup.
|
||||
|
||||
@@ -3052,6 +3228,10 @@ def register(ctx) -> None:
|
||||
# cron jobs route to the configured home space without editing
|
||||
# cron/scheduler.py's hardcoded sets.
|
||||
cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via the Chat REST API. Without this
|
||||
# hook, deliver=google_chat cron jobs fail with "No live adapter"
|
||||
# when cron runs separately from the gateway.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Auth env vars for _is_user_authorized() integration.
|
||||
allowed_users_env="GOOGLE_CHAT_ALLOWED_USERS",
|
||||
allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS",
|
||||
|
||||
@@ -53,11 +53,6 @@ from gateway.session import SessionSource
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
|
||||
|
||||
def _ensure_imports():
|
||||
"""No-op — kept for backward compatibility with any call sites."""
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IRC protocol helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -704,8 +699,233 @@ def _env_enablement() -> dict | None:
|
||||
return seed
|
||||
|
||||
|
||||
def _strip_irc_control_chars(text: str) -> str:
|
||||
"""Strip IRC line terminators and the NUL byte from ``text``.
|
||||
|
||||
IRC commands are CRLF-delimited; a bare ``\\r`` or ``\\n`` in user
|
||||
content lets an attacker inject arbitrary IRC commands (CTCP, JOIN,
|
||||
KICK). ``\\x00`` is a protocol-illegal byte. Everything else is
|
||||
valid in PRIVMSG payloads.
|
||||
"""
|
||||
return text.replace("\r", " ").replace("\n", " ").replace("\x00", "")
|
||||
|
||||
|
||||
def _is_irc_channel(target: str) -> bool:
|
||||
return bool(target) and target[0] in "#&+!"
|
||||
|
||||
|
||||
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]:
|
||||
"""Open an ephemeral IRC connection, send a PRIVMSG, and quit.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (e.g. ``hermes cron`` running as a
|
||||
separate process from ``hermes gateway``). Without this hook,
|
||||
``deliver=irc`` cron jobs fail with ``No live adapter for platform``.
|
||||
|
||||
The standalone client uses a distinct nick suffix (``-cron``) so it
|
||||
does not collide with the long-running gateway adapter that may already
|
||||
be holding the configured nickname on the same network. When the
|
||||
target is a channel, the client JOINs it before sending PRIVMSG so
|
||||
networks with the default ``+n`` (no external messages) channel mode
|
||||
accept the delivery.
|
||||
|
||||
``thread_id`` and ``media_files`` are accepted for signature parity but
|
||||
are not meaningful on IRC: IRC has no native thread or attachment
|
||||
primitive.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
if not server or not channel:
|
||||
return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"}
|
||||
|
||||
port_value = os.getenv("IRC_PORT") or extra.get("port", 6697)
|
||||
try:
|
||||
port = int(port_value)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": f"IRC standalone send: invalid port {port_value!r}"}
|
||||
|
||||
nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
||||
use_tls_env = os.getenv("IRC_USE_TLS")
|
||||
if use_tls_env is not None:
|
||||
use_tls = use_tls_env.lower() in ("1", "true", "yes")
|
||||
else:
|
||||
use_tls = bool(extra.get("use_tls", True))
|
||||
|
||||
server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
||||
nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
||||
|
||||
# Reject control characters in chat_id to block IRC command injection.
|
||||
raw_target = chat_id or channel
|
||||
if any(ch in raw_target for ch in ("\r", "\n", "\x00", " ")):
|
||||
return {"error": "IRC standalone send: chat_id contains illegal IRC characters"}
|
||||
target = raw_target
|
||||
|
||||
# Distinct nick prevents NICK collision with a live gateway adapter
|
||||
# that may already be holding the configured nickname. Cap to 24 chars
|
||||
# so subsequent collision retries do not overflow the 30-char NICKLEN
|
||||
# most networks enforce.
|
||||
nick_base = nickname.rstrip("_0123456789-")[:24] or "hermes-bot"
|
||||
standalone_nick = f"{nick_base}-cron"[:30]
|
||||
plain = IRCAdapter._strip_markdown(message)
|
||||
|
||||
ssl_ctx = ssl.create_default_context() if use_tls else None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(server, port, ssl=ssl_ctx),
|
||||
timeout=15.0,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"IRC standalone connect failed: {e}"}
|
||||
|
||||
async def _raw(line: str) -> None:
|
||||
writer.write((line + "\r\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
nick_attempts = 0
|
||||
max_nick_attempts = 5
|
||||
try:
|
||||
if server_password:
|
||||
await _raw(f"PASS {_strip_irc_control_chars(server_password)}")
|
||||
await _raw(f"NICK {standalone_nick}")
|
||||
await _raw(f"USER {standalone_nick} 0 * :Hermes Agent (cron)")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 15.0
|
||||
registered = False
|
||||
while not registered:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
||||
try:
|
||||
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
||||
except asyncio.IncompleteReadError:
|
||||
return {"error": "IRC standalone send: server closed connection during registration"}
|
||||
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
msg = _parse_irc_message(decoded)
|
||||
cmd = msg["command"]
|
||||
if cmd == "PING":
|
||||
payload = msg["params"][0] if msg["params"] else ""
|
||||
await _raw(f"PONG :{payload}")
|
||||
elif cmd == "001":
|
||||
registered = True
|
||||
elif cmd in ("432", "433"):
|
||||
nick_attempts += 1
|
||||
if nick_attempts > max_nick_attempts:
|
||||
return {"error": "IRC standalone send: too many nick collisions"}
|
||||
# Build the next nick from the stable base, not the
|
||||
# mutated value, so the suffix stays bounded.
|
||||
standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30]
|
||||
await _raw(f"NICK {standalone_nick}")
|
||||
elif cmd in ("464", "465"):
|
||||
return {"error": f"IRC standalone send: server rejected client ({cmd})"}
|
||||
|
||||
if nickserv_password:
|
||||
await _raw(f"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# JOIN before PRIVMSG. IRC channels with the default ``+n`` mode
|
||||
# (no external messages: Libera, OFTC, EFnet, IRCNet, undernet)
|
||||
# silently drop PRIVMSG from non-members. Do not JOIN bare nicks
|
||||
# (DM target) or server queries.
|
||||
if _is_irc_channel(target):
|
||||
await _raw(f"JOIN {target}")
|
||||
join_deadline = loop.time() + 5.0
|
||||
joined = False
|
||||
while not joined:
|
||||
remaining = join_deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
# Timed out waiting for a JOIN ack: proceed anyway, the
|
||||
# server may still deliver the PRIVMSG depending on mode.
|
||||
break
|
||||
try:
|
||||
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
||||
except (asyncio.TimeoutError, asyncio.IncompleteReadError):
|
||||
break
|
||||
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
jmsg = _parse_irc_message(decoded)
|
||||
jcmd = jmsg["command"]
|
||||
if jcmd == "PING":
|
||||
payload = jmsg["params"][0] if jmsg["params"] else ""
|
||||
await _raw(f"PONG :{payload}")
|
||||
elif jcmd in ("366", "JOIN"):
|
||||
joined = True
|
||||
elif jcmd in ("403", "405", "471", "473", "474", "475"):
|
||||
return {"error": f"IRC standalone send: JOIN {target} rejected ({jcmd})"}
|
||||
|
||||
# Bytes-aware per-line splitting so multi-line plain text never
|
||||
# exceeds the IRC 510-byte protocol limit. Reuses the same
|
||||
# algorithm as IRCAdapter._split_message, with control-character
|
||||
# stripping per line to block CRLF injection from message content.
|
||||
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2
|
||||
max_bytes = 510 - overhead
|
||||
sent_any = False
|
||||
for paragraph in plain.split("\n"):
|
||||
paragraph = _strip_irc_control_chars(paragraph).rstrip()
|
||||
if not paragraph:
|
||||
continue
|
||||
while paragraph:
|
||||
encoded = paragraph.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
await _raw(f"PRIVMSG {target} :{paragraph}")
|
||||
await asyncio.sleep(0.3)
|
||||
sent_any = True
|
||||
break
|
||||
# Binary search for largest prefix that fits within max_bytes
|
||||
low, high, best = 1, len(paragraph), 0
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if len(paragraph[:mid].encode("utf-8")) <= max_bytes:
|
||||
best = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
split_at = best
|
||||
space = paragraph.rfind(" ", 0, split_at)
|
||||
if space > split_at // 3:
|
||||
split_at = space
|
||||
await _raw(f"PRIVMSG {target} :{paragraph[:split_at].rstrip()}")
|
||||
await asyncio.sleep(0.3)
|
||||
sent_any = True
|
||||
paragraph = paragraph[split_at:].lstrip()
|
||||
|
||||
if not sent_any:
|
||||
return {"error": "IRC standalone send: empty message after stripping"}
|
||||
|
||||
await _raw("QUIT :delivered")
|
||||
try:
|
||||
await asyncio.wait_for(reader.read(1024), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
return {"success": True, "message_id": str(int(time.time() * 1000))}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("IRC standalone send raised", exc_info=True)
|
||||
return {"error": f"IRC standalone send failed: {e}"}
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
"""Plugin entry point: called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
@@ -716,7 +936,7 @@ def register(ctx):
|
||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||
install_hint="No extra packages needed (stdlib only)",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — seeds PlatformConfig.extra with
|
||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||
# server/channel/port/tls + home_channel so env-only setups show
|
||||
# up in gateway status without instantiating the adapter.
|
||||
env_enablement_fn=_env_enablement,
|
||||
@@ -724,6 +944,10 @@ def register(ctx):
|
||||
# IRC_CHANNEL (see _env_enablement), so cron jobs with
|
||||
# deliver=irc route to the joined channel by default.
|
||||
cron_deliver_env_var="IRC_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery. Without this hook, deliver=irc
|
||||
# cron jobs fail with "No live adapter" when cron runs separately
|
||||
# from the gateway.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="IRC_ALLOWED_USERS",
|
||||
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||
|
||||
@@ -418,6 +418,9 @@ def _env_enablement() -> dict | None:
|
||||
seed["port"] = int(port)
|
||||
except ValueError:
|
||||
pass
|
||||
service_url = os.getenv("TEAMS_SERVICE_URL", "").strip()
|
||||
if service_url:
|
||||
seed["service_url"] = service_url
|
||||
home = os.getenv("TEAMS_HOME_CHANNEL", "").strip()
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
@@ -427,6 +430,173 @@ def _env_enablement() -> dict | None:
|
||||
return seed
|
||||
|
||||
|
||||
# Bot Framework default service URL for the global Teams endpoint. Some
|
||||
# regional/government tenants need a different host (e.g.
|
||||
# ``https://smba.infra.gov.teams.microsoft.us/``) which can be supplied via
|
||||
# ``TEAMS_SERVICE_URL`` or ``extra['service_url']``.
|
||||
_DEFAULT_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/"
|
||||
|
||||
# Allowlist of Bot Framework service hosts that may receive a freshly
|
||||
# minted bearer token. Operator-supplied URLs are matched against this
|
||||
# allowlist to block SSRF / token-exfiltration via a tampered env var.
|
||||
_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({
|
||||
"smba.trafficmanager.net",
|
||||
"smba.infra.gov.teams.microsoft.us",
|
||||
})
|
||||
|
||||
# Conservative pattern for Bot Framework conversation IDs. Real values
|
||||
# combine digits, colons, hyphens, dots, '@', and the ``thread.skype`` /
|
||||
# ``thread.tacv2`` suffixes; reject anything outside this set so a hostile
|
||||
# value cannot path-traverse out of ``/v3/conversations/<id>/activities``.
|
||||
import re as _re_teams
|
||||
_TEAMS_CONV_ID_RE = _re_teams.compile(r"^[A-Za-z0-9:@\-_.]+$")
|
||||
|
||||
|
||||
def _validate_teams_service_url(raw: str) -> Optional[str]:
|
||||
"""Return a normalized service URL or ``None`` if it is not allowed.
|
||||
|
||||
Requires ``https://`` and a host in ``_ALLOWED_TEAMS_SERVICE_HOSTS``.
|
||||
The trailing slash is added if absent so callers can append
|
||||
``v3/conversations/...`` without double slashes.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if parsed.scheme != "https":
|
||||
return None
|
||||
if parsed.hostname not in _ALLOWED_TEAMS_SERVICE_HOSTS:
|
||||
return None
|
||||
normalized = raw if raw.endswith("/") else raw + "/"
|
||||
return normalized
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[list] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Acquire a Bot Framework bearer token and POST a single message activity.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (e.g. ``hermes cron`` running as a
|
||||
separate process from ``hermes gateway``). Without this hook,
|
||||
``deliver=teams`` cron jobs fail with ``No live adapter for platform``.
|
||||
|
||||
Configuration: requires ``TEAMS_CLIENT_ID``, ``TEAMS_CLIENT_SECRET``,
|
||||
``TEAMS_TENANT_ID``, ``TEAMS_HOME_CHANNEL`` (the conversation ID), and
|
||||
optionally ``TEAMS_SERVICE_URL`` (Bot Framework service host; must be
|
||||
a known Bot Framework endpoint, see ``_ALLOWED_TEAMS_SERVICE_HOSTS``).
|
||||
|
||||
Security: ``service_url`` is validated against an allowlist of known
|
||||
Bot Framework hosts to block SSRF / token-exfiltration via a tampered
|
||||
env var. ``chat_id`` is validated to match the documented Bot
|
||||
Framework ID character set so it cannot escape the URL path.
|
||||
|
||||
``media_files`` and ``force_document`` are accepted for signature
|
||||
parity but not implemented for the standalone path; messages with
|
||||
attachments will send as text-only. The live adapter handles
|
||||
attachments via the SDK.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
|
||||
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
|
||||
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
|
||||
if not (client_id and client_secret and tenant_id):
|
||||
return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"}
|
||||
|
||||
raw_service_url = (
|
||||
os.getenv("TEAMS_SERVICE_URL")
|
||||
or extra.get("service_url", "")
|
||||
or _DEFAULT_TEAMS_SERVICE_URL
|
||||
)
|
||||
service_url = _validate_teams_service_url(raw_service_url)
|
||||
if service_url is None:
|
||||
return {"error": (
|
||||
f"Teams standalone send: TEAMS_SERVICE_URL host is not on the "
|
||||
f"Bot Framework allowlist; expected one of "
|
||||
f"{sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"
|
||||
)}
|
||||
|
||||
# Bot Framework conversation IDs are restricted to a known character
|
||||
# set; anything else means a tampered chat_id trying to break out of
|
||||
# the URL path.
|
||||
if not chat_id:
|
||||
return {"error": "Teams standalone send: chat_id (conversation ID) is required"}
|
||||
if not _TEAMS_CONV_ID_RE.match(chat_id):
|
||||
return {"error": "Teams standalone send: chat_id contains characters outside the Bot Framework conversation ID set"}
|
||||
if not _TEAMS_CONV_ID_RE.match(tenant_id):
|
||||
return {"error": "Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set"}
|
||||
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
activities_url = f"{service_url}v3/conversations/{chat_id}/activities"
|
||||
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
return {"error": "Teams standalone send: aiohttp not installed"}
|
||||
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
|
||||
# Per-request timeouts so a slow STS endpoint cannot starve the
|
||||
# subsequent activity POST of its budget.
|
||||
per_request_timeout = _aiohttp.ClientTimeout(total=15.0)
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "https://api.botframework.com/.default",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=per_request_timeout,
|
||||
) as token_resp:
|
||||
if token_resp.status >= 400:
|
||||
body = await token_resp.text()
|
||||
return {"error": f"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}"}
|
||||
token_payload = await token_resp.json()
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
return {"error": "Teams standalone send: token response missing access_token"}
|
||||
|
||||
activity = {
|
||||
"type": "message",
|
||||
"text": message,
|
||||
"textFormat": "markdown",
|
||||
}
|
||||
async with session.post(
|
||||
activities_url,
|
||||
json=activity,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=per_request_timeout,
|
||||
) as send_resp:
|
||||
if send_resp.status >= 400:
|
||||
body = await send_resp.text()
|
||||
return {"error": f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}"}
|
||||
send_payload = await send_resp.json()
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": send_payload.get("id"),
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Teams standalone send raised", exc_info=True)
|
||||
return {"error": f"Teams standalone send failed: {e}"}
|
||||
|
||||
|
||||
# Keep the old name as an alias so existing test imports don't break.
|
||||
check_teams_requirements = check_requirements
|
||||
|
||||
@@ -985,6 +1155,10 @@ def register(ctx) -> None:
|
||||
# jobs route to the configured Teams chat/channel without editing
|
||||
# cron/scheduler.py's hardcoded sets.
|
||||
cron_deliver_env_var="TEAMS_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via Bot Framework REST. Without
|
||||
# this hook, deliver=teams cron jobs fail with "No live adapter"
|
||||
# when cron runs separately from the gateway.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="TEAMS_ALLOWED_USERS",
|
||||
allow_all_env="TEAMS_ALLOW_ALL_USERS",
|
||||
|
||||
Reference in New Issue
Block a user