diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index e025a0ca9b4e1..a8b40285e8811 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -583,6 +583,37 @@ def _is_valid_env_name(name: str) -> bool: return all(c.isalnum() or c == "_" for c in name) +def map_secrets_for_env( + secrets: Dict[str, str], + *, + key_prefix: str = "", + strip_prefix: bool = False, +) -> Tuple[Dict[str, str], List[str]]: + """Filter and optionally rename BSM secrets before applying to env. + + ``key_prefix`` lets one Bitwarden project hold several profile-specific + values (for example ``PROFILE_FRONTEND_OPENAI_API_KEY``). When + ``strip_prefix`` is true, only the matching prefix is removed before the + value is exported, so the example above becomes ``OPENAI_API_KEY``. + """ + if not key_prefix: + return dict(secrets), [] + + mapped: Dict[str, str] = {} + warnings: List[str] = [] + for key, value in secrets.items(): + if not key.startswith(key_prefix): + continue + env_key = key[len(key_prefix):] if strip_prefix else key + if not _is_valid_env_name(env_key): + warnings.append( + f"Skipping secret {key!r}: mapped env-var name {env_key!r} is invalid" + ) + continue + mapped[env_key] = value + return mapped, warnings + + # --------------------------------------------------------------------------- # Public entry point — called from hermes_cli.env_loader # --------------------------------------------------------------------------- @@ -598,6 +629,8 @@ def apply_bitwarden_secrets( auto_install: bool = True, server_url: str = "", home_path: Optional[Path] = None, + key_prefix: str = "", + strip_prefix: bool = False, ) -> FetchResult: """Pull secrets from BSM and set them on ``os.environ``. @@ -654,8 +687,15 @@ def apply_bitwarden_secrets( result.error = str(exc) return result + secrets, mapping_warnings = map_secrets_for_env( + secrets, + key_prefix=key_prefix, + strip_prefix=strip_prefix, + ) + result.secrets = secrets result.warnings.extend(warnings) + result.warnings.extend(mapping_warnings) for key, value in secrets.items(): if key == access_token_env: diff --git a/gateway/config.py b/gateway/config.py index a29f730692485..aca88f6646240 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1008,6 +1008,8 @@ def _merge_platform_map(source_platforms: Any) -> None: bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"] if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] + if plat in {Platform.DISCORD, Platform.SLACK} and "channel_profile_bindings" in platform_cfg: + bridged["channel_profile_bindings"] = platform_cfg["channel_profile_bindings"] if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] if isinstance(channel_prompts, dict): diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8c447a7a2bf2f..ae5af47e085d4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1787,6 +1787,59 @@ def resolve_channel_skills( return None +def resolve_channel_profile( + config_extra: dict, + channel_id: str, + parent_id: str | None = None, +) -> str | None: + """Resolve a profile route for a channel/thread from platform config. + + Looks up ``channel_profile_bindings`` in the adapter's ``config.extra`` dict. + + Supported formats:: + + channel_profile_bindings: + "1518643081354936400": "peniby-pm" + + channel_profile_bindings: + - id: "1518643081354936400" + profile: "peniby-pm" + + Prefers an exact match on *channel_id*; falls back to *parent_id* so + Discord/Slack threads can inherit a parent channel's profile route. Returns + the profile name, or None if no binding is found. + """ + bindings = config_extra.get("channel_profile_bindings") or {} + ids_to_check: list[str] = [] + for raw in (channel_id, parent_id): + if raw: + val = str(raw) + if val not in ids_to_check: + ids_to_check.append(val) + if not ids_to_check: + return None + + if isinstance(bindings, dict): + for key in ids_to_check: + profile = bindings.get(key) + if isinstance(profile, str) and profile.strip(): + return profile.strip() + return None + + if isinstance(bindings, list): + for key in ids_to_check: + for entry in bindings: + if not isinstance(entry, dict): + continue + entry_id = str(entry.get("id", "")) + if entry_id != key: + continue + profile = entry.get("profile") or entry.get("profile_name") + if isinstance(profile, str) and profile.strip(): + return profile.strip() + return None + + def _strip_media_directives(text: str) -> str: """Strip internal delivery directives ([[audio_as_voice]], [[as_document]], MEDIA:) so they never render as visible text. diff --git a/gateway/run.py b/gateway/run.py index 5220606a520b2..6fe262b83c43c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3052,18 +3052,19 @@ def _session_key_for_source(self, source: SessionSource) -> str: pass config = getattr(self, "config", None) # Mirror SessionStore._resolve_profile_for_key so this fallback path - # produces the same namespace as the primary path: None (legacy - # agent:main) unless multiplexing is on, then the active profile. + # produces the same namespace as the primary path: explicitly routed + # sources use their profile even without full adapter multiplexing; + # otherwise None (legacy agent:main) unless multiplexing is on, then the + # active profile. _profile = None - if getattr(config, "multiplex_profiles", False): - if source.profile: - _profile = source.profile - else: - try: - from hermes_cli.profiles import get_active_profile_name - _profile = get_active_profile_name() or "default" - except Exception: - _profile = None + if source.profile: + _profile = source.profile + elif getattr(config, "multiplex_profiles", False): + try: + from hermes_cli.profiles import get_active_profile_name + _profile = get_active_profile_name() or "default" + except Exception: + _profile = None return build_session_key( source, group_sessions_per_user=getattr(config, "group_sessions_per_user", True), @@ -7190,6 +7191,54 @@ async def _deliver_platform_notice(self, source, content: str) -> None: await adapter.send(source.chat_id, content, metadata=metadata) + def _apply_channel_profile_binding(self, event: MessageEvent) -> None: + """Stamp ``event.source.profile`` from per-channel profile bindings. + + This lets a shared platform bot route one specific channel/thread to a + specialist profile (config/skills/memory/credentials) without starting a + second adapter with the same Discord/Slack token. + """ + source = getattr(event, "source", None) + if source is None or getattr(source, "profile", None): + return + platform = getattr(source, "platform", None) + if platform is None: + return + try: + platform_cfg = self.config.platforms.get(platform) + extra = getattr(platform_cfg, "extra", None) if platform_cfg is not None else None + if not isinstance(extra, dict): + return + from gateway.platforms.base import resolve_channel_profile + profile = resolve_channel_profile( + extra, + str(getattr(source, "chat_id", "") or ""), + str(getattr(source, "parent_chat_id", "") or "") or None, + ) + if not profile: + return + from hermes_cli.profiles import get_profile_dir, validate_profile_name + validate_profile_name(profile) + profile_home = get_profile_dir(profile) + if not profile_home.is_dir(): + logger.warning( + "Ignoring channel_profile_bindings route to missing profile '%s' " + "for %s chat %s", + profile, + platform.value, + getattr(source, "chat_id", ""), + ) + return + source.profile = profile + logger.info( + "Routed %s chat %s to profile '%s' via channel_profile_bindings", + platform.value, + getattr(source, "chat_id", ""), + profile, + ) + except Exception as exc: + logger.warning("Failed to apply channel profile binding: %s", exc) + async def _handle_message(self, event: MessageEvent) -> Optional[str]: """ Handle an incoming message from any platform. @@ -7204,6 +7253,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: 7. Return response """ source = event.source + self._apply_channel_profile_binding(event) + source = event.source if ( getattr(self, "_startup_restore_in_progress", False) @@ -14256,7 +14307,10 @@ async def _run_agent( multiplexing is off this is a transparent pass-through — zero behavior change for single-profile gateways. """ - if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + if not ( + getattr(getattr(self, "config", None), "multiplex_profiles", False) + or getattr(source, "profile", None) + ): return await self._run_agent_inner( message, context_prompt, history, source, session_id, session_key=session_key, run_generation=run_generation, diff --git a/gateway/session.py b/gateway/session.py index d07c65ec29f6c..9301de281fbf4 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -813,16 +813,18 @@ def _save(self) -> None: def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Optional[str]: """Return the profile namespace for session keys, or None when off. - When ``multiplex_profiles`` is disabled (default), returns ``None`` so - keys stay in the legacy ``agent:main`` namespace — byte-identical to - before. When enabled, prefers the profile the inbound source was routed - to (``source.profile`` — set by the /p// URL prefix or - per-credential adapter), falling back to the active profile name. + When ``source.profile`` is explicitly set, always use that namespace. + This supports profile-routed gateway surfaces (for example one Discord + channel owned by a specialist profile) without requiring the gateway to + start every profile adapter. Otherwise, when ``multiplex_profiles`` is + disabled (default), returns ``None`` so keys stay in the legacy + ``agent:main`` namespace. When multiplexing is enabled, falls back to + the active profile name. """ - if not getattr(self.config, "multiplex_profiles", False): - return None if source is not None and source.profile: return source.profile + if not getattr(self.config, "multiplex_profiles", False): + return None try: from hermes_cli.profiles import get_active_profile_name return get_active_profile_name() or "default" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 27c56974b4a04..6ab6de5717203 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2094,6 +2094,7 @@ def _ensure_hermes_home_managed(home: Path): "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) + "channel_profile_bindings": {}, # Per-channel profile routes, e.g. {"123": "project-pm"} # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES # authorizes only guild messages in the role's own guild — DMs require # DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also @@ -2120,6 +2121,15 @@ def _ensure_hermes_home_managed(home: Path): # real memory cost. Default 32 MiB matches the historical hardcoded # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. "max_attachment_bytes": 33554432, + # Voice-channel input chunking. Lower silence threshold reduces how + # long the user waits after they stop speaking before STT starts. Forced + # max chunks are disabled by default to avoid prematurely dispatching + # incomplete action requests; set >0 to transcribe long continuous speech + # or noisy streams in bounded chunks. + "voice_receiver": { + "silence_threshold": 0.75, # Seconds of packet silence before STT + "max_chunk_duration": 0.0, # Seconds of buffered audio; 0 disables + }, # Voice-channel audio effects (the continuous mixer). OFF by default. # When enabled, the bot installs a software mixer on the outgoing voice # stream so a low ambient "thinking" bed, verbal acknowledgements, and @@ -2751,6 +2761,15 @@ def _ensure_hermes_home_managed(home: Path): # as BWS_SERVER_URL. Prompted for during # `hermes secrets bitwarden setup`. "server_url": "", + # Optional namespace filter for shared Bitwarden projects. When + # set, only secrets whose names start with this prefix are loaded. + # This prevents one profile from accidentally importing another + # profile's tokens from the same project. + "key_prefix": "", + # If key_prefix is set, strip it before exporting matching secrets + # into os.environ. Example: PROFILE_PM_OPENAI_API_KEY -> + # OPENAI_API_KEY for a PM profile with key_prefix=PROFILE_PM_. + "strip_prefix": False, }, }, diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index c7d507d8c2f3b..11989a5ed0f65 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -325,6 +325,8 @@ def _apply_external_secret_sources(home_path: Path) -> None: auto_install=bool(bw_cfg.get("auto_install", True)), server_url=str(bw_cfg.get("server_url", "") or "").strip(), home_path=home_path, + key_prefix=str(bw_cfg.get("key_prefix", "") or ""), + strip_prefix=bool(bw_cfg.get("strip_prefix", False)), ) if result.applied: diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index cc31cb331609d..18123e37567ec 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -243,6 +243,12 @@ def cmd_setup(args: argparse.Namespace) -> int: use_cache=False, server_url=server_url, ) + secrets, mapping_warnings = bw.map_secrets_for_env( + secrets, + key_prefix=str(secrets_cfg.get("key_prefix", "") or ""), + strip_prefix=bool(secrets_cfg.get("strip_prefix", False)), + ) + warnings.extend(mapping_warnings) except Exception as exc: # noqa: BLE001 console.print(f" [red]✗ Fetch failed: {exc}[/red]") return 1 @@ -310,6 +316,8 @@ def cmd_status(args: argparse.Namespace) -> int: "Server URL", server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]", ) + table.add_row("Key prefix", bw_cfg.get("key_prefix") or "[dim](none)[/dim]") + table.add_row("Strip prefix", _yn(bool(bw_cfg.get("strip_prefix", False)))) table.add_row("Override existing", _yn(bool(bw_cfg.get("override_existing", False)))) table.add_row("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))) table.add_row("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))) @@ -368,6 +376,12 @@ def cmd_sync(args: argparse.Namespace) -> int: use_cache=False, server_url=server_url, ) + secrets, mapping_warnings = bw.map_secrets_for_env( + secrets, + key_prefix=str(bw_cfg.get("key_prefix", "") or ""), + strip_prefix=bool(bw_cfg.get("strip_prefix", False)), + ) + warnings.extend(mapping_warnings) except Exception as exc: # noqa: BLE001 console.print(f"[red]Fetch failed: {exc}[/red]") return 1 diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index accede61a2340..2a933eed1476f 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -335,15 +335,37 @@ class VoiceReceiver: completed utterances via a callback. """ - SILENCE_THRESHOLD = 1.5 # seconds of silence → end of utterance + SILENCE_THRESHOLD = 0.75 # seconds of silence → end of utterance MIN_SPEECH_DURATION = 0.5 # minimum seconds to process (skip noise) + MAX_CHUNK_DURATION = 0.0 # seconds of audio before forced chunk; 0 disables SAMPLE_RATE = 48000 # Discord native rate CHANNELS = 2 # Discord sends stereo - def __init__(self, voice_client, allowed_user_ids: set = None): + def __init__( + self, + voice_client, + allowed_user_ids: Optional[set] = None, + *, + silence_threshold: Optional[float] = None, + max_chunk_duration: Optional[float] = None, + ): self._vc = voice_client self._allowed_user_ids = allowed_user_ids or set() self._running = False + self._silence_threshold = self._coerce_seconds( + silence_threshold, + default=self.SILENCE_THRESHOLD, + min_value=0.2, + max_value=5.0, + allow_zero=False, + ) + self._max_chunk_duration = self._coerce_seconds( + max_chunk_duration, + default=self.MAX_CHUNK_DURATION, + min_value=self.MIN_SPEECH_DURATION, + max_value=120.0, + allow_zero=True, + ) # Decryption self._secret_key: Optional[bytes] = None @@ -371,6 +393,30 @@ def __init__(self, voice_client, allowed_user_ids: set = None): # Lifecycle # ------------------------------------------------------------------ + @staticmethod + def _coerce_seconds( + value: Optional[float], + *, + default: float, + min_value: float, + max_value: float, + allow_zero: bool, + ) -> float: + """Return a safe seconds value for voice chunking settings.""" + if value is None or isinstance(value, bool): + return default + try: + seconds = float(value) + except (TypeError, ValueError): + return default + if allow_zero and seconds <= 0: + return 0.0 + if seconds < min_value: + return min_value + if seconds > max_value: + return max_value + return seconds + def start(self): """Start listening for voice packets.""" conn = self._vc._connection @@ -634,7 +680,15 @@ def check_silence(self) -> list: # 48kHz, 16-bit, stereo = 192000 bytes/sec buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2) - if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION: + silence_finished = silence_duration >= self._silence_threshold + max_chunk_finished = ( + self._max_chunk_duration > 0 + and buf_duration >= self._max_chunk_duration + ) + if ( + buf_duration >= self.MIN_SPEECH_DURATION + and (silence_finished or max_chunk_finished) + ): user_id = ssrc_user_map.get(ssrc, 0) if not user_id: # SSRC not mapped (SPEAKING event missing after bot rejoin). @@ -644,7 +698,7 @@ def check_silence(self) -> list: completed.append((user_id, bytes(buf))) self._buffers[ssrc] = bytearray() self._last_packet_time.pop(ssrc, None) - elif silence_duration >= self.SILENCE_THRESHOLD * 2: + elif silence_duration >= self._silence_threshold * 2: # Stale buffer with no valid user — discard self._buffers.pop(ssrc, None) self._last_packet_time.pop(ssrc, None) @@ -769,6 +823,7 @@ def __init__(self, config: PlatformConfig): self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config() + self._voice_receiver_cfg: Dict[str, float] = self._load_voice_receiver_config() # Track threads where the bot has participated so follow-up messages # in those threads don't require @mention. Persisted to disk so the # set survives gateway restarts. @@ -2196,6 +2251,44 @@ async def send_voice( # Voice channel methods (join / leave / play) # ------------------------------------------------------------------ + def _load_voice_receiver_config(self) -> Dict[str, float]: + """Read Discord voice input chunking settings from config.yaml. + + Behavioral settings live under ``discord.voice_receiver`` (not .env): + ``silence_threshold`` controls how quickly a packet gap ends an + utterance, and ``max_chunk_duration`` optionally emits a chunk during + continuous speech/noise. ``max_chunk_duration: 0`` disables forced + chunking to avoid premature action triggers by default. + """ + defaults: Dict[str, float] = { + "silence_threshold": VoiceReceiver.SILENCE_THRESHOLD, + "max_chunk_duration": VoiceReceiver.MAX_CHUNK_DURATION, + } + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() or {} + raw = (cfg.get("discord") or {}).get("voice_receiver") or {} + if isinstance(raw, dict): + silence_raw = raw.get("silence_threshold", raw.get("silence_threshold_seconds")) + max_raw = raw.get("max_chunk_duration", raw.get("max_chunk_duration_seconds")) + defaults["silence_threshold"] = VoiceReceiver._coerce_seconds( + silence_raw, + default=defaults["silence_threshold"], + min_value=0.2, + max_value=5.0, + allow_zero=False, + ) + defaults["max_chunk_duration"] = VoiceReceiver._coerce_seconds( + max_raw, + default=defaults["max_chunk_duration"], + min_value=VoiceReceiver.MIN_SPEECH_DURATION, + max_value=120.0, + allow_zero=True, + ) + except Exception as e: + logger.debug("Could not load discord.voice_receiver config: %s", e) + return defaults + def _load_voice_fx_config(self) -> Dict[str, Any]: """Read voice mixer / ambient / ack settings from config.yaml. @@ -2373,7 +2466,12 @@ async def join_voice_channel(self, channel) -> bool: # Start voice receiver (Phase 2: listen to users) try: - receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids) + receiver = VoiceReceiver( + vc, + allowed_user_ids=self._allowed_user_ids, + silence_threshold=self._voice_receiver_cfg.get("silence_threshold"), + max_chunk_duration=self._voice_receiver_cfg.get("max_chunk_duration"), + ) receiver.start() self._voice_receivers[guild_id] = receiver self._voice_listen_tasks[guild_id] = asyncio.ensure_future( diff --git a/pyproject.toml b/pyproject.toml index d269ba840be20..6426a7f788ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -350,6 +350,7 @@ testpaths = ["tests"] markers = [ "integration: marks tests requiring external services (API keys, Modal, etc.)", "real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances", + "contract: reusable contract suites for external/backlog implementations", ] # integration tests take way too long to run in the normal CI environments addopts = "-m 'not integration'" diff --git a/tests/contracts/README.md b/tests/contracts/README.md new file mode 100644 index 0000000000000..51874090bbbb2 --- /dev/null +++ b/tests/contracts/README.md @@ -0,0 +1,76 @@ +# Noise-gate contract tests + +This directory contains the standardized QA contract suite for the Hermes/OpenClaw `BE-REL-NOISE-001` / `BE-NG-QA-SUPPORT-001` noise-gate behavior. + +## Purpose + +The suite turns the QA checklist into reusable pytest tests for any backend implementation of: + +```python +evaluate_event(event: dict) -> dict +``` + +It covers: + +1. Duplicate replay: first event accepted, replay suppressed. +2. Canonicalization: formatting-only/key-order changes cannot bypass dedupe. +3. TTL/window: duplicate inside TTL suppressed; after TTL accepted as a new window. +4. Cross-profile independence: separate profiles do not suppress each other by default. +5. Invalid key behavior: malformed/unsafe events are rejected before side effects. +6. Observable metadata: audit-safe hashes only; no raw private identifiers. +7. Concurrency: parallel duplicates produce exactly one accepted result. + +## Running against an implementation + +Point the test suite at the backend callable: + +```bash +HERMES_NOISE_GATE_EVALUATE_EVENT="package.module:evaluate_event" \ +python -m pytest tests/contracts/test_noise_gate_contract.py -q -m contract +``` + +If `HERMES_NOISE_GATE_EVALUATE_EVENT` is unset, the tests skip. This lets the repository carry the standard before the backend target exists, while making future implementation proof one command away. + +## Required event input shape + +The callable receives one dict with these fields: + +1. `profile` +2. `lane` +3. `event_type` +4. `source_scope_hash` +5. `semantic_payload` +6. `semantic_event_hash` +7. `ttl_seconds` +8. `created_at` + +The suite uses unique `lane` values per test so a durable backend store does not need manual cleanup between tests. + +## Required result shape + +The callable should return a dict-like `NoiseGateResult` with at least: + +1. `decision` +2. `side_effect_allowed` +3. `dedupe_key_hash` +4. `observable_metadata.source_scope_hash` +5. `observable_metadata.semantic_event_hash` +6. `observable_metadata.raw_private_fields_present` + +Accepted decisions: + +1. `accepted` +2. `accepted_after_window` + +Suppression decision: + +1. `suppressed_duplicate` + +Rejected decisions: + +1. `rejected_invalid_key` +2. `rejected_invalid_ttl` + +## Privacy rules + +Do not include raw chat IDs, thread IDs, email addresses, tokens, credentials, or private message/email contents in implementation results or logs. The tests assert that observable metadata stays hash-only and that invalid raw source scopes are rejected before side effects. diff --git a/tests/contracts/test_noise_gate_contract.py b/tests/contracts/test_noise_gate_contract.py new file mode 100644 index 0000000000000..079b4f17fc25a --- /dev/null +++ b/tests/contracts/test_noise_gate_contract.py @@ -0,0 +1,287 @@ +"""Reusable contract tests for Hermes/OpenClaw noise-gate backends. + +These tests standardize the BE-REL-NOISE-001 / BE-NG-QA-SUPPORT-001 +acceptance checklist. They are intentionally implementation-agnostic: point the +suite at a candidate backend with: + + HERMES_NOISE_GATE_EVALUATE_EVENT="package.module:evaluate_event" + +The target callable must accept one event dict and return a dict-like +NoiseGateResult. If the env var is unset, the suite skips so the repository CI +can carry the standard before an implementation exists. +""" + +from __future__ import annotations + +import copy +import hashlib +import importlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from typing import Any, Callable +from uuid import uuid4 + +import pytest + + +pytestmark = pytest.mark.contract + +TARGET_ENV = "HERMES_NOISE_GATE_EVALUATE_EVENT" +SAFE_SOURCE_SCOPE_HASH = hashlib.sha256(b"qa-noise-gate-contract-source").hexdigest() +ACCEPTED_DECISIONS = {"accepted", "accepted_after_window"} +REJECTED_DECISIONS = {"rejected_invalid_key", "rejected_invalid_ttl"} + + +def _normalize_semantic(value: Any) -> Any: + if isinstance(value, str): + return " ".join(value.strip().split()) + if isinstance(value, list): + return [_normalize_semantic(item) for item in value] + if isinstance(value, dict): + return {key: _normalize_semantic(value[key]) for key in sorted(value)} + return value + + +def _canonical_json(value: Any) -> str: + normalized = _normalize_semantic(value) + return json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _semantic_hash(payload: Any) -> str: + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def _load_evaluate_event() -> Callable[[dict[str, Any]], Any]: + target = os.environ.get(TARGET_ENV) + if not target: + pytest.skip( + f"Set {TARGET_ENV}=package.module:evaluate_event to run the " + "standard noise-gate contract suite against an implementation." + ) + if ":" not in target: + raise AssertionError(f"{TARGET_ENV} must use package.module:function form") + module_name, function_name = target.split(":", 1) + module = importlib.import_module(module_name) + evaluate_event = getattr(module, function_name) + if not callable(evaluate_event): + raise AssertionError(f"{target} is not callable") + return evaluate_event + + +@pytest.fixture() +def evaluate_event() -> Callable[[dict[str, Any]], dict[str, Any]]: + target = _load_evaluate_event() + + def call(event: dict[str, Any]) -> dict[str, Any]: + result = target(copy.deepcopy(event)) + if hasattr(result, "model_dump"): + result = result.model_dump() + elif hasattr(result, "to_dict"): + result = result.to_dict() + elif hasattr(result, "__dict__") and not isinstance(result, dict): + result = vars(result) + assert isinstance(result, dict), "NoiseGateResult must be dict-like" + return result + + return call + + +@pytest.fixture() +def lane() -> str: + return f"qa-noise-gate-contract-{uuid4().hex}" + + +def _event( + lane: str, + *, + profile: str = "backend", + event_type: str = "standup_update", + payload: dict[str, Any] | None = None, + ttl_seconds: int = 60, + created_at: datetime | None = None, +) -> dict[str, Any]: + payload = payload or { + "action_item_id": "BE-NG-QA-SUPPORT-001", + "state_before": "pending", + "state_after": "ready_for_qa", + "target_channel_class": "audit_log", + "business_key": "noise-gate-contract-proof", + "normalized_summary": "backend contract event ready for QA", + } + created_at = created_at or datetime(2026, 5, 12, 17, 0, tzinfo=UTC) + return { + "profile": profile, + "lane": lane, + "event_type": event_type, + "source_scope_hash": SAFE_SOURCE_SCOPE_HASH, + "semantic_payload": payload, + "semantic_event_hash": _semantic_hash(payload), + "ttl_seconds": ttl_seconds, + "created_at": created_at.isoformat().replace("+00:00", "Z"), + } + + +def _decision(result: dict[str, Any]) -> str: + decision = result.get("decision") + assert isinstance(decision, str), "NoiseGateResult.decision must be a string" + return decision + + +def _assert_no_side_effect(result: dict[str, Any]) -> None: + assert result.get("side_effect_allowed") is False + + +def test_duplicate_replay_accepts_first_event_and_suppresses_replay( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + event = _event(lane) + + first = evaluate_event(event) + replay = evaluate_event(event) + + assert _decision(first) == "accepted" + assert first.get("side_effect_allowed") is True + assert _decision(replay) == "suppressed_duplicate" + _assert_no_side_effect(replay) + assert replay.get("dedupe_key_hash") == first.get("dedupe_key_hash") + + +def test_canonicalization_suppresses_formatting_only_payload_changes( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + first_payload = { + "state_after": "ready_for_qa", + "action_item_id": "BE-NG-QA-SUPPORT-001", + "normalized_summary": "backend contract event ready for QA", + "tags": ["qa", "noise-gate", "backend"], + } + equivalent_payload = { + "tags": ["qa", "noise-gate", "backend"], + "normalized_summary": " backend contract event ready for QA ", + "action_item_id": "BE-NG-QA-SUPPORT-001", + "state_after": "ready_for_qa", + } + + first = evaluate_event(_event(lane, payload=first_payload)) + replay = evaluate_event(_event(lane, payload=equivalent_payload)) + + assert _decision(first) == "accepted" + assert _decision(replay) == "suppressed_duplicate" + _assert_no_side_effect(replay) + + +def test_ttl_window_suppresses_inside_window_and_accepts_after_expiry( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + start = datetime(2026, 5, 12, 17, 0, tzinfo=UTC) + payload = {"action_item_id": "BE-NG-QA-SUPPORT-001", "normalized_summary": "ttl probe"} + + first = evaluate_event(_event(lane, payload=payload, ttl_seconds=60, created_at=start)) + inside_window = evaluate_event( + _event(lane, payload=payload, ttl_seconds=60, created_at=start + timedelta(seconds=30)) + ) + after_window = evaluate_event( + _event(lane, payload=payload, ttl_seconds=60, created_at=start + timedelta(seconds=90)) + ) + + assert _decision(first) == "accepted" + assert _decision(inside_window) == "suppressed_duplicate" + _assert_no_side_effect(inside_window) + assert _decision(after_window) in ACCEPTED_DECISIONS + assert after_window.get("side_effect_allowed") is True + + +def test_cross_profile_independence_accepts_same_semantics_for_different_profiles( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + payload = { + "action_item_id": "BE-NG-QA-SUPPORT-001", + "normalized_summary": "same semantic event from separate profiles", + } + + backend_result = evaluate_event(_event(lane, profile="backend", payload=payload)) + qa_result = evaluate_event(_event(lane, profile="qa", payload=payload)) + + assert _decision(backend_result) == "accepted" + assert backend_result.get("side_effect_allowed") is True + assert _decision(qa_result) == "accepted" + assert qa_result.get("side_effect_allowed") is True + assert backend_result.get("dedupe_key_hash") != qa_result.get("dedupe_key_hash") + + +@pytest.mark.parametrize( + ("mutation", "expected_reason_fragment"), + [ + (lambda event: event.pop("profile"), "profile"), + (lambda event: event.pop("event_type"), "event_type"), + (lambda event: event.pop("semantic_event_hash"), "semantic_event_hash"), + ( + lambda event: ( + event.pop("source_scope_hash"), + event.update({"source_scope": "telegram:-1001234567890:secret-thread"}), + ), + "privacy", + ), + (lambda event: event.update({"ttl_seconds": 0}), "ttl"), + ], +) +def test_invalid_events_are_rejected_before_side_effects( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], + lane: str, + mutation: Callable[[dict[str, Any]], Any], + expected_reason_fragment: str, +) -> None: + event = _event(lane) + mutation(event) + + result = evaluate_event(event) + + assert _decision(result) in REJECTED_DECISIONS + _assert_no_side_effect(result) + serialized = json.dumps(result, sort_keys=True, default=str).lower() + assert expected_reason_fragment in serialized + + +def test_observable_metadata_is_audit_safe_and_hash_only( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + event = _event(lane) + + result = evaluate_event(event) + + assert _decision(result) == "accepted" + assert result.get("dedupe_key_hash") + metadata = result.get("observable_metadata") + assert isinstance(metadata, dict) + assert metadata.get("source_scope_hash") == SAFE_SOURCE_SCOPE_HASH + assert metadata.get("semantic_event_hash") == event["semantic_event_hash"] + assert metadata.get("raw_private_fields_present") is False + serialized = json.dumps(result, sort_keys=True, default=str) + assert "telegram:-100" not in serialized + assert "@" not in serialized + assert "gho_" not in serialized + assert "token" not in serialized.lower() + + +def test_concurrent_duplicate_submissions_have_one_winner( + evaluate_event: Callable[[dict[str, Any]], dict[str, Any]], lane: str +) -> None: + event = _event( + lane, + payload={ + "action_item_id": "BE-NG-QA-SUPPORT-001", + "normalized_summary": "concurrent duplicate submission", + }, + ttl_seconds=300, + ) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: evaluate_event(event), range(8))) + + decisions = [_decision(result) for result in results] + assert decisions.count("accepted") == 1 + assert decisions.count("suppressed_duplicate") == 7 + assert sum(result.get("side_effect_allowed") is True for result in results) == 1 + assert sum(result.get("side_effect_allowed") is False for result in results) == 7 diff --git a/tests/gateway/test_channel_profile_bindings.py b/tests/gateway/test_channel_profile_bindings.py new file mode 100644 index 0000000000000..79b90d5e04619 --- /dev/null +++ b/tests/gateway/test_channel_profile_bindings.py @@ -0,0 +1,28 @@ +"""Per-channel profile routing helpers.""" + +from gateway.platforms.base import resolve_channel_profile + + +def test_resolve_channel_profile_dict_exact_match(): + extra = {"channel_profile_bindings": {"1518643081354936400": "peniby-pm"}} + assert resolve_channel_profile(extra, "1518643081354936400") == "peniby-pm" + + +def test_resolve_channel_profile_dict_parent_fallback(): + extra = {"channel_profile_bindings": {"parent": "peniby-pm"}} + assert resolve_channel_profile(extra, "thread", "parent") == "peniby-pm" + + +def test_resolve_channel_profile_list_format(): + extra = { + "channel_profile_bindings": [ + {"id": "other", "profile": "other-pm"}, + {"id": "1518643081354936400", "profile": "peniby-pm"}, + ] + } + assert resolve_channel_profile(extra, "1518643081354936400") == "peniby-pm" + + +def test_resolve_channel_profile_blank_or_missing_is_none(): + assert resolve_channel_profile({}, "1518643081354936400") is None + assert resolve_channel_profile({"channel_profile_bindings": {"x": " "}}, "x") is None diff --git a/tests/gateway/test_multiplex_phase0.py b/tests/gateway/test_multiplex_phase0.py index 0297b08494c9d..ec12970a757da 100644 --- a/tests/gateway/test_multiplex_phase0.py +++ b/tests/gateway/test_multiplex_phase0.py @@ -146,6 +146,20 @@ def test_flag_off_uses_legacy_namespace(self, tmp_path): assert store._generate_session_key(s) == "agent:main:telegram:dm:99" assert store._generate_session_key(s) == build_session_key(s) + def test_explicit_source_profile_is_honored_even_when_flag_off(self, tmp_path): + store = self._store(tmp_path) # no full adapter multiplexing required + s = _src( + platform=Platform.DISCORD, + chat_id="g1", + chat_type="group", + user_id="alice", + profile="peniby-pm", + ) + assert ( + store._generate_session_key(s) + == "agent:peniby-pm:discord:group:g1:alice" + ) + def test_flag_off_resolve_profile_is_none(self, tmp_path): store = self._store(tmp_path) assert store._resolve_profile_for_key() is None diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 4d52591a230aa..8b87a6de5287c 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -717,6 +717,48 @@ def test_check_silence_ignores_recent_audio(self): completed = receiver.check_silence() assert len(completed) == 0 + def test_custom_silence_threshold_finishes_utterance_sooner(self): + from plugins.platforms.discord.adapter import VoiceReceiver + + mock_vc = MagicMock() + mock_vc._connection.secret_key = [0] * 32 + mock_vc._connection.dave_session = None + mock_vc._connection.ssrc = 9999 + mock_vc._connection.add_socket_listener = MagicMock() + mock_vc._connection.remove_socket_listener = MagicMock() + mock_vc._connection.hook = None + receiver = VoiceReceiver(mock_vc, silence_threshold=0.4) + receiver.map_ssrc(100, 42) + receiver._buffers[100] = bytearray(b"\x00" * 96000) + receiver._last_packet_time[100] = time.monotonic() - 0.45 + + completed = receiver.check_silence() + + assert len(completed) == 1 + assert completed[0][0] == 42 + + def test_max_chunk_duration_finishes_without_waiting_for_silence(self): + from plugins.platforms.discord.adapter import VoiceReceiver + + mock_vc = MagicMock() + mock_vc._connection.secret_key = [0] * 32 + mock_vc._connection.dave_session = None + mock_vc._connection.ssrc = 9999 + mock_vc._connection.add_socket_listener = MagicMock() + mock_vc._connection.remove_socket_listener = MagicMock() + mock_vc._connection.hook = None + receiver = VoiceReceiver(mock_vc, max_chunk_duration=0.75) + receiver.map_ssrc(100, 42) + # 1.0s of PCM; last packet is current, so only max_chunk can fire. + receiver._buffers[100] = bytearray(b"\x00" * 192000) + receiver._last_packet_time[100] = time.monotonic() + + completed = receiver.check_silence() + + assert len(completed) == 1 + assert completed[0][0] == 42 + assert len(completed[0][1]) == 192000 + def test_check_silence_unknown_user_discarded(self): receiver = self._make_receiver() # No SSRC mapping — user_id will be 0 @@ -1203,6 +1245,32 @@ async def test_get_user_voice_channel_success(self): result = await adapter.get_user_voice_channel(111, "42") assert result is mock_vc + @pytest.mark.asyncio + async def test_join_voice_channel_passes_receiver_chunk_config(self): + adapter = self._make_adapter() + adapter._voice_receiver_cfg = { + "silence_threshold": 0.45, + "max_chunk_duration": 6.0, + } + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + mock_channel = MagicMock() + mock_channel.guild.id = 111 + mock_channel.id = 222 + mock_channel.connect = AsyncMock(return_value=mock_vc) + receiver = MagicMock() + + with patch("plugins.platforms.discord.adapter.VoiceReceiver", return_value=receiver) as receiver_cls: + assert await adapter.join_voice_channel(mock_channel) is True + + receiver_cls.assert_called_once_with( + mock_vc, + allowed_user_ids=adapter._allowed_user_ids, + silence_threshold=0.45, + max_chunk_duration=6.0, + ) + receiver.start.assert_called_once() + @pytest.mark.asyncio async def test_play_in_voice_channel_not_connected(self): adapter = self._make_adapter() diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 5f84004ee802d..4ed3f3fa4ad76 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -969,6 +969,7 @@ def test_default_config_enables_cli_refresh_interval(self): class TestDiscordChannelPromptsConfig: def test_default_config_includes_discord_channel_prompts(self): assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {} + assert DEFAULT_CONFIG["discord"]["channel_profile_bindings"] == {} def test_migrate_adds_discord_channel_prompts_default(self, tmp_path): config_path = tmp_path / "config.yaml" @@ -985,6 +986,7 @@ def test_migrate_adds_discord_channel_prompts_default(self, tmp_path): assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] assert raw["discord"]["auto_thread"] is True assert raw["discord"]["channel_prompts"] == {} + assert raw["discord"]["channel_profile_bindings"] == {} class TestUserMessagePreviewConfig: diff --git a/tests/test_bitwarden_secrets.py b/tests/test_bitwarden_secrets.py index ac5057c18b804..1158bd32c8239 100644 --- a/tests/test_bitwarden_secrets.py +++ b/tests/test_bitwarden_secrets.py @@ -564,6 +564,85 @@ def test_apply_override_existing(monkeypatch, tmp_path): assert os.environ["OPENAI_API_KEY"] == "fresh" +def test_apply_filters_by_key_prefix_and_strips_prefix(monkeypatch, tmp_path): + monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("PROFILE_OTHER_OPENAI_API_KEY", raising=False) + fake_binary = tmp_path / "bws" + fake_binary.write_text("") + payload = _fake_bws_payload([ + {"key": "PROFILE_MAIN_OPENAI_API_KEY", "value": "main-value"}, + {"key": "PROFILE_OTHER_OPENAI_API_KEY", "value": "other-value"}, + ]) + monkeypatch.setattr( + bw.subprocess, "run", + lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""), + ) + monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary) + + result = bw.apply_bitwarden_secrets( + enabled=True, project_id="p", auto_install=False, + key_prefix="PROFILE_MAIN_", strip_prefix=True, + ) + + assert result.ok + assert result.secrets == {"OPENAI_API_KEY": "main-value"} + assert result.applied == ["OPENAI_API_KEY"] + assert os.environ["OPENAI_API_KEY"] == "main-value" + assert "PROFILE_OTHER_OPENAI_API_KEY" not in os.environ + + +def test_apply_key_prefix_without_strip_filters_only(monkeypatch, tmp_path): + monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t") + monkeypatch.delenv("PROFILE_MAIN_OPENAI_API_KEY", raising=False) + fake_binary = tmp_path / "bws" + fake_binary.write_text("") + payload = _fake_bws_payload([ + {"key": "PROFILE_MAIN_OPENAI_API_KEY", "value": "main-value"}, + {"key": "UNRELATED_KEY", "value": "ignored"}, + ]) + monkeypatch.setattr( + bw.subprocess, "run", + lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""), + ) + monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary) + + result = bw.apply_bitwarden_secrets( + enabled=True, project_id="p", auto_install=False, + key_prefix="PROFILE_MAIN_", strip_prefix=False, + ) + + assert result.ok + assert result.secrets == {"PROFILE_MAIN_OPENAI_API_KEY": "main-value"} + assert result.applied == ["PROFILE_MAIN_OPENAI_API_KEY"] + assert os.environ["PROFILE_MAIN_OPENAI_API_KEY"] == "main-value" + assert "UNRELATED_KEY" not in os.environ + + +def test_apply_warns_when_stripped_env_name_is_invalid(monkeypatch, tmp_path): + monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t") + fake_binary = tmp_path / "bws" + fake_binary.write_text("") + payload = _fake_bws_payload([ + {"key": "PROFILE_MAIN_1INVALID", "value": "ignored"}, + ]) + monkeypatch.setattr( + bw.subprocess, "run", + lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""), + ) + monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary) + + result = bw.apply_bitwarden_secrets( + enabled=True, project_id="p", auto_install=False, + key_prefix="PROFILE_MAIN_", strip_prefix=True, + ) + + assert result.ok + assert result.secrets == {} + assert result.applied == [] + assert any("mapped env-var name '1INVALID' is invalid" in w for w in result.warnings) + + def test_apply_never_overrides_bootstrap_token(monkeypatch, tmp_path): """Even with override_existing=True, the access-token var is preserved.""" monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.original") @@ -633,6 +712,8 @@ def test_env_loader_calls_bsm_when_enabled(tmp_path, monkeypatch): " cache_ttl_seconds: 0\n" " override_existing: false\n" " auto_install: false\n" + " key_prefix: 'PROFILE_MAIN_'\n" + " strip_prefix: true\n" ) monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t") @@ -643,6 +724,8 @@ def fake_apply(**kwargs): called["n"] += 1 assert kwargs["enabled"] is True assert kwargs["project_id"] == "proj-1" + assert kwargs["key_prefix"] == "PROFILE_MAIN_" + assert kwargs["strip_prefix"] is True os.environ["MY_BSM_KEY"] = "from-bsm" return bw.FetchResult( secrets={"MY_BSM_KEY": "from-bsm"}, diff --git a/website/docs/user-guide/secrets/bitwarden.md b/website/docs/user-guide/secrets/bitwarden.md index 3e5185124729d..f52cdce19e7c6 100644 --- a/website/docs/user-guide/secrets/bitwarden.md +++ b/website/docs/user-guide/secrets/bitwarden.md @@ -88,6 +88,8 @@ secrets: cache_ttl_seconds: 300 override_existing: true auto_install: true + key_prefix: "" + strip_prefix: false ``` | Key | Default | What it does | @@ -99,6 +101,23 @@ secrets: | `cache_ttl_seconds` | `300` | How long an in-process fetch result is reused. Set to `0` to disable caching. Cache is per-process; new `hermes` invocations start fresh. | | `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. | | `auto_install` | `true` | When true, `bws` is auto-downloaded into `~/.hermes/bin/` on first use. | +| `key_prefix` | `""` | Optional filter for shared Bitwarden projects. When set, Hermes loads only secrets whose names start with this prefix. | +| `strip_prefix` | `false` | When true and `key_prefix` is set, Hermes removes the prefix before exporting the env var. Example: `PROFILE_PM_OPENAI_API_KEY` becomes `OPENAI_API_KEY` with `key_prefix: PROFILE_PM_`. | + +### Profile-prefixed secrets + +If several Hermes profiles share one Bitwarden Secrets Manager project, avoid storing profile-specific values under the same exact env-var name. Use a profile namespace instead: + +```yaml +secrets: + bitwarden: + enabled: true + project_id: "..." + key_prefix: PROFILE_PM_ + strip_prefix: true +``` + +With that config, a Bitwarden secret named `PROFILE_PM_TELEGRAM_BOT_TOKEN` is exported to the process as `TELEGRAM_BOT_TOKEN`, while `PROFILE_SOCIAL_TELEGRAM_BOT_TOKEN` is ignored by this profile. ## Failure modes