mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 22:36:02 +00:00
feat(box): add box.enabled toggle and gate consumers on availability
Make the Box sandbox runtime optional. When ``box.enabled`` is false in
config (or when an enabled Box fails to connect), every dependent feature
degrades to the same disabled-state UX rather than crashing or silently
falling back to less safe code paths.
Backend:
- config.yaml: new top-level ``box.enabled: true`` flag (default true)
- BoxService:
- Read box.enabled on construction
- initialize() short-circuits when disabled — no remote WS connect, no
stdio subprocess fork
- _on_runtime_disconnect is a no-op when disabled (no reconnect loop
on a deliberately-off service)
- get_status() now exposes ``enabled`` so the frontend can tell
"disabled in config" from "configured but failed"
- MCP stdio loader (mcp_stdio.uses_box_stdio): requires box_service to
be available, not just installed
- MCP _init_stdio_python_server: when ap.box_service exists but is
unavailable, refuse the stdio server with an actionable error instead
of silently falling through to host-stdio (which bypasses the sandbox
the operator asked for). Setups without ap.box_service installed at
all keep the legacy host-stdio fallback for pre-Box dev mode
- SkillService._require_box_for_write: refuses create/update/install/
write_skill_file when ap.box_service is installed but unavailable.
Distinguishes disabled vs failed in the error message so the UI can
surface the right hint. Legacy setups (no ap.box_service) keep the
local fallback path — that distinction is what keeps the existing
local-skills tests valid
Tests:
- Box disabled-state behavior (4 cases)
- Skill write refusal in disabled & failed states (7 cases)
- MCP stdio runtime info policy updated to match new refuse-when-down
behavior
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,34 @@ class SkillService:
|
||||
return box_service
|
||||
return None
|
||||
|
||||
def _require_box_for_write(self, action: str) -> None:
|
||||
"""Refuse a write operation when Box is installed but unavailable.
|
||||
|
||||
Distinguishes three states:
|
||||
- Box available → no-op (caller proceeds via the Box delegate path).
|
||||
- Box installed but disabled by config or currently failed → raise
|
||||
with a clear, actionable message. The frontend translates this to
|
||||
a banner / disabled affordance.
|
||||
- Box not installed at all (legacy / pre-Box dev mode, no
|
||||
``ap.box_service`` attribute) → also no-op so the local-skills
|
||||
fallback still works for that minimal setup.
|
||||
"""
|
||||
ap_box = getattr(self.ap, 'box_service', None)
|
||||
if ap_box is None:
|
||||
return # legacy mode, allow local fallback
|
||||
if getattr(ap_box, 'available', False):
|
||||
return # Box is up, delegate path will be used
|
||||
if not getattr(ap_box, 'enabled', True):
|
||||
reason = 'disabled in config (box.enabled = false)'
|
||||
else:
|
||||
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
||||
reason = f'unavailable: {connector_error}'
|
||||
raise ValueError(
|
||||
f'{action} requires the Box runtime, which is {reason}. '
|
||||
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
||||
f'runtime is reachable before retrying.'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_skill(skill: dict) -> dict:
|
||||
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
|
||||
@@ -100,6 +128,7 @@ class SkillService:
|
||||
return await self.get_skill(name)
|
||||
|
||||
async def create_skill(self, data: dict) -> dict:
|
||||
self._require_box_for_write('Creating a skill')
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
created = await box_service.create_skill(data)
|
||||
@@ -146,6 +175,7 @@ class SkillService:
|
||||
return created
|
||||
|
||||
async def update_skill(self, skill_name: str, data: dict) -> dict:
|
||||
self._require_box_for_write('Editing a skill')
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
updated = await box_service.update_skill(skill_name, data)
|
||||
@@ -266,6 +296,7 @@ class SkillService:
|
||||
}
|
||||
|
||||
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
|
||||
self._require_box_for_write('Editing skill files')
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
result = await box_service.write_skill_file(skill_name, path, content)
|
||||
@@ -294,6 +325,7 @@ class SkillService:
|
||||
}
|
||||
|
||||
async def install_from_github(self, data: dict) -> list[dict]:
|
||||
self._require_box_for_write('Installing a skill from GitHub')
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -375,6 +407,7 @@ class SkillService:
|
||||
source_paths: list[str] | None = None,
|
||||
source_path: str = '',
|
||||
) -> list[dict]:
|
||||
self._require_box_for_write('Installing a skill from upload')
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
installed = await box_service.install_skill_zip(
|
||||
|
||||
Reference in New Issue
Block a user