diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 916e203a1bb4..231749a35996 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -16,6 +16,9 @@ logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} +# Per-platform cache — prevents disabled-skill views from one platform +# leaking into another when the gateway serves multiple adapters (#14536). +_skill_commands_by_platform: Dict[str, Dict[str, Dict[str, Any]]] = {} # Patterns for sanitizing skill names into clean hyphen-separated slugs. _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") @@ -376,11 +379,23 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: return _skill_commands -def get_skill_commands() -> Dict[str, Dict[str, Any]]: - """Return the current skill commands mapping (scan first if empty).""" +def get_skill_commands(platform: str = "") -> Dict[str, Dict[str, Any]]: + """Return the current skill commands mapping (scan first if empty). + + When *platform* is provided, returns a platform-specific view that + may exclude skills disabled for that platform. The base (unfiltered) + scan result is cached globally; per-platform views are cached + separately to prevent cross-platform cache leakage (#14536). + """ if not _skill_commands: scan_skill_commands() - return _skill_commands + if not platform: + return _skill_commands + if platform in _skill_commands_by_platform: + return _skill_commands_by_platform[platform] + # Build per-platform view — callers can filter further + _skill_commands_by_platform[platform] = dict(_skill_commands) + return _skill_commands_by_platform[platform] def resolve_skill_command_key(command: str) -> Optional[str]: diff --git a/tests/agent/test_skill_commands_platform_cache.py b/tests/agent/test_skill_commands_platform_cache.py new file mode 100644 index 000000000000..14b3dc1983f0 --- /dev/null +++ b/tests/agent/test_skill_commands_platform_cache.py @@ -0,0 +1,40 @@ +"""Tests for skill_commands per-platform cache isolation (#14536).""" +import pytest + + +class TestSkillCommandsPlatformCache: + """Per-platform skill caches must be isolated (#14536).""" + + def test_get_skill_commands_accepts_platform(self): + from agent.skill_commands import get_skill_commands + import inspect + sig = inspect.signature(get_skill_commands) + assert "platform" in sig.parameters + + def test_per_platform_cache_exists(self): + import agent.skill_commands as mod + assert hasattr(mod, '_skill_commands_by_platform') + + def test_platform_isolation(self): + """Different platforms get independent cache entries.""" + import agent.skill_commands as mod + mod._skill_commands_by_platform.clear() + mod._skill_commands = {"/test-skill": {"name": "test"}} + + telegram_cmds = mod.get_skill_commands(platform="telegram") + discord_cmds = mod.get_skill_commands(platform="discord") + + # Remove a skill from telegram's view + telegram_cmds.pop("/test-skill", None) + + # Discord should still have it + assert "/test-skill" in discord_cmds, ( + "Removing skill from telegram cache leaked into discord cache" + ) + + def test_no_platform_returns_global(self): + """Calling without platform returns the global cache.""" + import agent.skill_commands as mod + mod._skill_commands = {"/global": {"name": "global"}} + result = mod.get_skill_commands() + assert "/global" in result