Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions agent/secret_sources/bitwarden.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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``.

Expand Down Expand Up @@ -654,8 +687,15 @@ def apply_bitwarden_secrets(
result.error = str(exc)
return result

secrets, mapping_warnings = map_secrets_for_env(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main no longer calls apply_bitwarden_secrets() from startup: hermes_cli/env_loader.py delegates to registry.apply_all(), which uses BitwardenSource.fetch(). Apply this mapping in BitwardenSource.fetch() before it returns result.secrets, otherwise key_prefix and strip_prefix are bypassed at runtime.

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:
Expand Down
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
53 changes: 53 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<path>) so they never render as visible text.
Expand Down
78 changes: 66 additions & 12 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 9 additions & 7 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<profile>/ 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"
Expand Down
19 changes: 19 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
},

Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions hermes_cli/secrets_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))))
Expand Down Expand Up @@ -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
Expand Down
Loading