diff --git a/agent/skill_bundles.py b/agent/skill_bundles.py index 10836b359fe..ba7af103aa3 100644 --- a/agent/skill_bundles.py +++ b/agent/skill_bundles.py @@ -254,6 +254,7 @@ def build_bundle_invocation_message( cmd_key: str, user_instruction: str = "", task_id: str | None = None, + platform: str | None = None, ) -> Optional[Tuple[str, List[str], List[str]]]: """Build the user message content for a bundle slash command invocation. @@ -264,6 +265,16 @@ def build_bundle_invocation_message( loads — the agent gets a note about which ones were skipped. This is the same forgiving stance ``build_preloaded_skills_prompt`` uses for ``-s`` CLI preloading. + + Disabled skills are also skipped: bundles load members via + ``_load_skill_payload`` directly, bypassing the scan-time disabled + filter in ``get_skill_commands()``, so the disabled list must be + re-applied here. ``platform`` scopes the check to a specific + platform's ``skills.platform_disabled`` config (gateway dispatch + passes it explicitly because the gateway handles multiple platforms + in one process); when *None*, the platform resolves from session env + vars and the global disabled list still applies. Mirrors the + stacked-skill gate in gateway dispatch (#58888). """ bundles = get_skill_bundles() info = bundles.get(cmd_key) @@ -274,8 +285,15 @@ def build_bundle_invocation_message( # keep skill_bundles cheap to import in test environments. from agent.skill_commands import _load_skill_payload, _build_skill_message + try: + from agent.skill_utils import get_disabled_skill_names + disabled_names = get_disabled_skill_names(platform=platform) + except Exception: + disabled_names = set() + loaded_names: List[str] = [] missing: List[str] = [] + disabled: List[str] = [] skill_blocks: List[str] = [] seen: set[str] = set() @@ -295,6 +313,12 @@ def build_bundle_invocation_message( continue loaded_skill, skill_dir, skill_name = loaded + # Per-platform / global disabled gate. Checked against the loaded + # skill's canonical name (identifiers may be paths or aliases). + if skill_name in disabled_names or identifier in disabled_names: + disabled.append(skill_name or identifier) + continue + try: from tools.skill_usage import bump_use bump_use(skill_name) @@ -329,6 +353,10 @@ def build_bundle_invocation_message( ] if missing: header_lines.append(f"Skills missing (skipped): {', '.join(missing)}") + if disabled: + header_lines.append( + f"Skills disabled for this platform (skipped): {', '.join(disabled)}" + ) if extra_instruction: header_lines.extend(["", f"Bundle instruction: {extra_instruction}"]) if user_instruction: diff --git a/gateway/run.py b/gateway/run.py index e1ad23e0c68..b81e87dba64 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9845,8 +9845,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew bundle_key = resolve_bundle_command_key(command) if bundle_key is not None: user_instruction = event.get_command_args().strip() + # Pass the platform explicitly: bundle skill loading + # bypasses get_skill_commands()' scan-time disabled + # filter, and the gateway serves multiple platforms in + # one process, so env-var platform resolution can't be + # trusted here. Mirrors the stacked-skill gate (#58888). + _bundle_plat = source.platform.value if source.platform else None bundle_result = build_bundle_invocation_message( - bundle_key, user_instruction, task_id=_quick_key + bundle_key, user_instruction, task_id=_quick_key, + platform=_bundle_plat, ) if bundle_result: msg, _loaded, missing = bundle_result diff --git a/tests/agent/test_skill_bundles.py b/tests/agent/test_skill_bundles.py index ba2ea8ad94b..a98a4986b01 100644 --- a/tests/agent/test_skill_bundles.py +++ b/tests/agent/test_skill_bundles.py @@ -213,6 +213,53 @@ class TestBuildBundleInvocationMessage: assert missing == ["skill-ghost"] assert "skill-ghost" in msg # called out in header + def test_skips_platform_disabled_skills(self, bundles_env, monkeypatch): + """A skill disabled for the invoking platform must not be injected + via a bundle (mirrors the stacked-skill gate, #58888).""" + bundles_dir, skills_dir = bundles_env + _make_skill(skills_dir, "skill-a", body="Skill A content.") + _make_skill(skills_dir, "skill-b", body="SECRET DISABLED CONTENT.") + _make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-b"]) + scan_bundles() + + def _fake_disabled(platform=None): + return {"skill-b"} if platform == "telegram" else set() + + import agent.skill_utils as su_module + monkeypatch.setattr( + su_module, "get_disabled_skill_names", _fake_disabled + ) + + result = build_bundle_invocation_message("/combo", platform="telegram") + assert result is not None + msg, loaded, missing = result + assert loaded == ["skill-a"] + assert "SECRET DISABLED CONTENT." not in msg + assert "skill-b" in msg # called out in the disabled-skipped header line + assert "disabled" in msg.lower() + + # Positive control: without the platform the skill loads normally. + result2 = build_bundle_invocation_message("/combo") + assert result2 is not None + msg2, loaded2, _ = result2 + assert set(loaded2) == {"skill-a", "skill-b"} + assert "SECRET DISABLED CONTENT." in msg2 + + def test_all_skills_disabled_returns_none(self, bundles_env, monkeypatch): + bundles_dir, skills_dir = bundles_env + _make_skill(skills_dir, "skill-a") + _make_bundle_yaml(bundles_dir, "solo", ["skill-a"]) + scan_bundles() + + import agent.skill_utils as su_module + monkeypatch.setattr( + su_module, + "get_disabled_skill_names", + lambda platform=None: {"skill-a"}, + ) + + assert build_bundle_invocation_message("/solo", platform="discord") is None + def test_unknown_bundle_returns_none(self, bundles_env): scan_bundles() assert build_bundle_invocation_message("/nope") is None