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:
Junyan Qin
2026-05-20 17:07:53 +08:00
parent 99328cf4c0
commit ec2d21fe63
8 changed files with 261 additions and 4 deletions

View File

@@ -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(