From 8737b818b6505a756e04148701a2f843acc9da90 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:21:26 +0000 Subject: [PATCH] fix(cloud): render empty skills without sandbox grant --- src/langbot/pkg/api/http/service/skill.py | 9 +++++- src/langbot/pkg/cloud/entitlements.py | 14 ++++++++- tests/unit_tests/test_skill_service.py | 37 +++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index 9073ccd4b..3ae8deed1 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -12,6 +12,7 @@ from urllib.parse import quote, unquote, urlparse import httpx +from ....cloud.entitlements import EntitlementFeatureUnavailableError from ....core import app from ....skill.utils import parse_frontmatter from ....utils import httpclient @@ -119,7 +120,13 @@ class SkillService: box_service = self._box_service() if box_service is None: return [] - return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)] + try: + skills = await box_service.list_skills(execution_context) + except EntitlementFeatureUnavailableError as error: + if error.feature == 'managed_sandbox': + return [] + raise + return [self._serialize_skill(skill) for skill in skills] async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]: execution_context = await self._execution_context(context) diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py index b9476e6ba..6ffee4878 100644 --- a/src/langbot/pkg/cloud/entitlements.py +++ b/src/langbot/pkg/cloud/entitlements.py @@ -17,6 +17,14 @@ class EntitlementUnavailableError(RuntimeError): self.entitlement_revision = entitlement_revision +class EntitlementFeatureUnavailableError(EntitlementUnavailableError): + """Raised when an active entitlement explicitly omits a capability.""" + + def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None: + super().__init__(message, entitlement_revision=entitlement_revision) + self.feature = feature + + class EntitlementSnapshot(pydantic.BaseModel): """Capability projection consumed by open-source Core. @@ -82,7 +90,11 @@ class EntitlementSnapshot(pydantic.BaseModel): def require_feature(self, feature: str) -> None: if self.features.get(feature) is not True: - raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}') + raise EntitlementFeatureUnavailableError( + f'Workspace entitlement does not grant {feature}', + feature=feature, + entitlement_revision=self.entitlement_revision, + ) def limit(self, name: str) -> int: value = self.limits.get(name) diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py index 5b2203beb..a9d173be6 100644 --- a/tests/unit_tests/test_skill_service.py +++ b/tests/unit_tests/test_skill_service.py @@ -8,6 +8,7 @@ import pytest from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.api.http.service.skill import SkillService +from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError _CONTEXT = ExecutionContext( @@ -113,6 +114,42 @@ class TestRequireBoxForWrite: service = SkillService(self._ap_with_disabled_box()) assert await service.list_skills(_CONTEXT) == [] + @pytest.mark.asyncio + async def test_list_skills_returns_empty_when_managed_sandbox_is_not_granted(self): + box_service = SimpleNamespace( + available=True, + list_skills=AsyncMock( + side_effect=EntitlementFeatureUnavailableError( + 'Workspace entitlement does not grant managed_sandbox', + feature='managed_sandbox', + ) + ), + ) + service = SkillService( + SimpleNamespace( + workspace_service=_workspace_service(), + box_service=box_service, + ) + ) + + assert await service.list_skills(_CONTEXT) == [] + + @pytest.mark.asyncio + async def test_list_skills_preserves_other_entitlement_failures(self): + box_service = SimpleNamespace( + available=True, + list_skills=AsyncMock(side_effect=EntitlementUnavailableError('control plane unavailable')), + ) + service = SkillService( + SimpleNamespace( + workspace_service=_workspace_service(), + box_service=box_service, + ) + ) + + with pytest.raises(EntitlementUnavailableError, match='control plane unavailable'): + await service.list_skills(_CONTEXT) + @pytest.mark.asyncio async def test_read_skill_file_refused_when_box_unavailable(self): service = SkillService(self._ap_with_disabled_box())