mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
The Box backends behave inconsistently when extra_mounts reference a missing host directory (nsjail aborts the entire sandbox start, Docker silently creates a root-owned empty dir on the host, E2B silently skips the upload). The cache in skill_mgr.skills is only refreshed on in-process mutations, so out-of-band changes — container rebuilds, manual rm in the box volume, anything the LangBot API didn't drive — leave a stale skill that later produces one of those bad mount paths. - box/service.py: build_skill_extra_mounts now filters skills whose package_root is not isdir on the LangBot-visible filesystem and logs a warning, instead of passing the bad mount through to the backend - skill/manager.py: reload_skills (Box path) drops skills whose package_root is missing on the LangBot-side filesystem before they reach the in-memory cache, with a summary warning - api/http/controller/groups/skills.py: file/CRUD handlers now also catch BoxError (RuntimeError subclass, previously slipping past ``except ValueError`` and surfacing as 500); list/get handlers gain a try/except so a transient Box RPC failure becomes a clean 400 instead of a stack trace Tests added for build_skill_extra_mounts (skip missing, skip empty, no skill manager) and SkillManager.reload_skills (drop missing on Box path). Full unit suite: 279 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
9.2 KiB
Python
191 lines
9.2 KiB
Python
from __future__ import annotations
|
|
|
|
import quart
|
|
|
|
from langbot_plugin.box.errors import BoxError
|
|
|
|
from .. import group
|
|
|
|
|
|
@group.group_class('skills', '/api/v1/skills')
|
|
class SkillsRouterGroup(group.RouterGroup):
|
|
"""Skills management API endpoints."""
|
|
|
|
async def initialize(self) -> None:
|
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def list_or_create_skills() -> quart.Response:
|
|
if quart.request.method == 'GET':
|
|
try:
|
|
skills = await self.ap.skill_service.list_skills()
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
return self.success(data={'skills': skills})
|
|
|
|
data = await quart.request.json
|
|
if 'name' not in data or not data['name']:
|
|
return self.http_status(400, -1, 'Missing required field: name')
|
|
|
|
try:
|
|
skill = await self.ap.skill_service.create_skill(data)
|
|
return self.success(data={'skill': skill})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def get_update_delete_skill(skill_name: str) -> quart.Response:
|
|
if quart.request.method == 'GET':
|
|
try:
|
|
skill = await self.ap.skill_service.get_skill(skill_name)
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
if not skill:
|
|
return self.http_status(404, -1, 'Skill not found')
|
|
return self.success(data={'skill': skill})
|
|
|
|
if quart.request.method == 'PUT':
|
|
data = await quart.request.json
|
|
try:
|
|
skill = await self.ap.skill_service.update_skill(skill_name, data)
|
|
return self.success(data={'skill': skill})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
try:
|
|
await self.ap.skill_service.delete_skill(skill_name)
|
|
return self.success()
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
@self.route('/<skill_name>/files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def list_skill_files(skill_name: str) -> quart.Response:
|
|
"""List files in skill package directory."""
|
|
path = quart.request.args.get('path', '.').strip()
|
|
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
|
|
|
|
try:
|
|
result = await self.ap.skill_service.list_skill_files(
|
|
skill_name,
|
|
path=path,
|
|
include_hidden=include_hidden,
|
|
)
|
|
return self.success(data=result)
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
@self.route(
|
|
'/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
)
|
|
async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
|
|
"""Read or write a file in skill package."""
|
|
if quart.request.method == 'GET':
|
|
try:
|
|
result = await self.ap.skill_service.read_skill_file(skill_name, path)
|
|
return self.success(data=result)
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
# PUT - write file
|
|
data = await quart.request.json
|
|
content = data.get('content', '')
|
|
if content is None:
|
|
return self.http_status(400, -1, 'Missing required field: content')
|
|
|
|
try:
|
|
result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
|
|
return self.success(data=result)
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
|
|
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def preview_skill(skill_name: str) -> quart.Response:
|
|
skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
|
|
if not skill:
|
|
return self.http_status(404, -1, 'Skill not found')
|
|
return self.success(data={'instructions': skill.get('instructions', '')})
|
|
|
|
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def install_skill_from_github() -> quart.Response:
|
|
data = await quart.request.json
|
|
required_fields = ['asset_url', 'owner', 'repo']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
return self.http_status(400, -1, f'Missing required field: {field}')
|
|
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
|
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
|
return self.http_status(400, -1, 'Missing required field: release_tag')
|
|
|
|
try:
|
|
skill = await self.ap.skill_service.install_from_github(data)
|
|
return self.success(data={'skills': skill})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
except Exception as exc:
|
|
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
|
|
|
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def preview_skill_from_github() -> quart.Response:
|
|
data = await quart.request.json
|
|
required_fields = ['asset_url', 'owner', 'repo']
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
return self.http_status(400, -1, f'Missing required field: {field}')
|
|
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
|
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
|
return self.http_status(400, -1, 'Missing required field: release_tag')
|
|
|
|
try:
|
|
preview = await self.ap.skill_service.preview_install_from_github(data)
|
|
return self.success(data={'skills': preview})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
except Exception as exc:
|
|
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
|
|
|
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def install_skill_from_upload() -> quart.Response:
|
|
file = (await quart.request.files).get('file')
|
|
if file is None:
|
|
return self.http_status(400, -1, 'file is required')
|
|
form = await quart.request.form
|
|
|
|
try:
|
|
skill = await self.ap.skill_service.install_from_zip_upload(
|
|
file_bytes=file.read(),
|
|
filename=file.filename or '',
|
|
source_paths=form.getlist('source_paths'),
|
|
)
|
|
return self.success(data={'skills': skill})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
except Exception as exc:
|
|
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
|
|
|
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def preview_skill_from_upload() -> quart.Response:
|
|
file = (await quart.request.files).get('file')
|
|
if file is None:
|
|
return self.http_status(400, -1, 'file is required')
|
|
|
|
try:
|
|
preview = await self.ap.skill_service.preview_install_from_zip_upload(
|
|
file_bytes=file.read(),
|
|
filename=file.filename or '',
|
|
)
|
|
return self.success(data={'skills': preview})
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|
|
except Exception as exc:
|
|
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
|
|
|
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
async def scan_skill_directory() -> quart.Response:
|
|
path = quart.request.args.get('path', '').strip()
|
|
if not path:
|
|
return self.http_status(400, -1, 'Missing required parameter: path')
|
|
|
|
try:
|
|
result = await self.ap.skill_service.scan_directory_async(path)
|
|
return self.success(data=result)
|
|
except (ValueError, BoxError) as exc:
|
|
return self.http_status(400, -1, str(exc))
|