feat(plugin): upload debug plugin to LangBot Space with optional GitH… (#2612)

* feat(plugin): upload debug plugin to LangBot Space with optional GitHub sync

- SDK: reusable in-memory .lbpkg packaging + git sync helper (local git by default, optional repo/token override)
- routing: space-upload config/upload/git-sync endpoints; debug plugins resolved via OSS bridge without touching get_plugin_info
- frontend: debug-card entry + upload dialog with multi-language metadata, icon upload and changelog
- i18n across 8 locales; operation-trace + action allowlist entries

* fix(plugin-space-upload): stable dialog width, single-line locale tabs, broader error surfacing

- fixed dialog width so toggling the GitHub section no longer resizes it
- language tabs render on one line (horizontal scroll when needed)
- surface the real upload/git error message (msg field) instead of [object Object]
- hint that a missing GitHub repo is auto-created when a token is provided

* fix(plugin-space-upload): narrow dialog and reset sm:max-w clamp

The base DialogContent applies sm:max-w-lg at the sm breakpoint, which overrode the unprefixed max-w-none and clamped the upload card to 32rem on desktop. Set an explicit sm:max-width and size the card to ~36rem. Locale tabs now wrap instead of scrolling horizontally.

* fix

* fix(plugin-space-upload): address CI and UX feedback

- narrow the upload dialog to ~36rem and reset the sm:max-w clamp
- guide the publisher to the Space review page after a successful upload
- drop the redundant null comparison flagged by code quality
- update the operation-action expectation and Space/LangBot Account wording
- ruff format the touched API modules

* feat(plugin-space-upload): public repo, editable author/license, card red dot

* fix(market): replace yellow download count with update available badge

* style(market): unify installed and update indicators into badges

* feat(plugin): redesign debug card banner, style debug icon, and prioritize debug plugins

* feat(plugin-space-upload): simplify upload dialog layout, add creator verification and market redirection

* fix(i18n): use LangBot Account terminology in Space upload dialog

Co-authored-by: multica-agent <[email protected]>

* style(i18n): reflow reworded locale strings for prettier

Co-authored-by: multica-agent <[email protected]>

* test(utils): stop timing out the pattern-count cap test on loaded runners

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Hyu <[email protected]>
Co-authored-by: RockChinQ <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Type_rBody
2026-10-28 18:23:09 +08:00
committed by GitHub
co-authored by multica-agent Hyu RockChinQ
parent 4687fc96e7
commit 4b537973f9
28 changed files with 3809 additions and 109 deletions
@@ -40,9 +40,11 @@ class ExtensionsRouterGroup(group.RouterGroup):
return_exceptions=True,
)
def _sort_key(item: dict) -> str:
def _sort_key(item: dict) -> tuple[int, str]:
is_debug = 0 if (item.get('type') == 'plugin' and item.get('plugin', {}).get('debug')) else 1
name = ''
if item['type'] == 'plugin':
return (
name = (
item['plugin']
.get('manifest', {})
.get('manifest', {})
@@ -50,11 +52,11 @@ class ExtensionsRouterGroup(group.RouterGroup):
.get('name', '')
.lower()
)
if item['type'] == 'mcp':
return (item['server'].get('name') or '').lower()
if item['type'] == 'skill':
return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower()
return ''
elif item['type'] == 'mcp':
name = (item['server'].get('name') or '').lower()
elif item['type'] == 'skill':
name = (item['skill'].get('display_name') or item['skill'].get('name') or '').lower()
return (is_debug, name)
extensions: list[dict] = []
if isinstance(plugins, list):
@@ -23,6 +23,7 @@ from ...context import ExecutionContext, RequestContext
from .....operation_trace import service as settings_service
from .. import group
from .....workspace.errors import WorkspaceNotFoundError
from langbot_plugin.entities.io.errors import ActionCallError
from .....plugin.github import validate_github_plugin_install_info
from .....plugin.archive import inspect_plugin_archive_metadata
from .....utils import httpclient
@@ -436,6 +437,257 @@ class PluginsRouterGroup(group.RouterGroup):
}
)
async def _space_access_token_for_workspace(
request_context: RequestContext,
) -> str | None:
"""Resolve the Workspace owner's or requesting account's LangBot Space token, if bound.
Every step is best-effort: a Space account is optional for the
underlying LangBot install, so an unavailable identity service must
not turn the upload page's metadata read into a 500.
"""
try:
user_service = getattr(self.ap, 'user_service', None)
space_service = getattr(self.ap, 'space_service', None)
if user_service is None or space_service is None:
return None
if request_context.account_uuid:
account = await user_service.get_user_by_uuid(request_context.account_uuid)
if account and account.account_type == 'space':
token = await space_service.get_valid_access_token(account.user)
if token:
return token
owner = await user_service.get_workspace_owner(request_context.workspace_uuid)
if owner is None:
return None
return await space_service.get_valid_access_token(owner.user)
except Exception:
return None
async def _resolve_upload_target(author: str, plugin_name: str) -> dict | None:
"""Resolve a plugin for the upload page, preferring debug connections.
A plugin shown as "debugging" is not persisted as an installation, so
it is looked up through the OSS debug bridge first; installed plugins
remain a fallback so the page also works for local/marketplace ones.
"""
connector = self.ap.plugin_connector
for resolver in (
getattr(connector, 'get_debug_plugin_info', None),
getattr(connector, 'get_plugin_info', None),
):
if resolver is None:
continue
try:
info = await resolver(author, plugin_name)
except Exception:
self.ap.logger.exception('Failed to resolve plugin %s/%s for upload', author, plugin_name)
continue
if info:
return info
return None
@self.route(
'/<author>/<plugin_name>/space-upload/config',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Metadata and connection state for the "upload to Space" page."""
await self._require_authenticated_plugin_runtime_context(request_context)
plugin = await _resolve_upload_target(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
metadata = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}) or {}
access_token = await _space_access_token_for_workspace(request_context)
space_connected = False
has_creator_profile = False
creator_slug = None
author_matches = False
if access_token:
try:
space_service = getattr(self.ap, 'space_service', None)
if space_service is not None:
token_valid, creator_profile = await space_service.get_creator_profile(access_token)
if token_valid:
space_connected = True
if creator_profile and creator_profile.get('slug'):
has_creator_profile = True
creator_slug = creator_profile['slug']
plugin_author = metadata.get('author', author)
if plugin_author and creator_slug and plugin_author.lower() == creator_slug.lower():
author_matches = True
except Exception:
self.ap.logger.exception('Failed to retrieve creator profile for space-upload/config')
return self.success(
data={
'debug': bool(plugin.get('debug')),
'metadata': {
'author': metadata.get('author', author),
'name': metadata.get('name', plugin_name),
'label': metadata.get('label'),
'description': metadata.get('description'),
'version': metadata.get('version'),
'repository': metadata.get('repository'),
'license': metadata.get('license'),
},
'space_connected': space_connected,
'has_creator_profile': has_creator_profile,
'creator_slug': creator_slug,
'author_matches': author_matches,
'cloud_service_url': self.ap.instance_config.data.get('space', {}).get(
'web_url',
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app'),
),
}
)
@self.route(
'/<author>/<plugin_name>/space-upload',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Build the debug plugin and publish it to LangBot Space.
Optionally synchronises the working directory to GitHub first, so the
published plugin and the repository stay aligned.
"""
await self._require_authenticated_plugin_runtime_context(request_context)
try:
data = await quart.request.get_json(silent=True) or {}
except Exception:
data = {}
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
overrides = data.get('manifest_overrides') or {}
if not isinstance(overrides, dict):
return self.http_status(400, -1, 'manifest_overrides must be an object')
sync_github = data.get('sync_github') is True
git_result: dict | None = None
try:
package_result = await self.ap.plugin_connector.get_plugin_package(
author,
plugin_name,
manifest_overrides=overrides,
)
except Exception as exc:
self.ap.logger.warning('Building package for %s/%s failed: %s', author, plugin_name, exc)
message = str(exc)
if 'build_plugin_package' in message and 'not found' in message.lower():
message = (
'The debugging plugin process does not support packaging yet. '
'Update the LangBot plugin SDK used by `lbp run` and restart it.'
)
return self.http_status(400, -1, message)
package_bytes = package_result.get('package') or b''
if not package_bytes:
return self.http_status(400, -1, 'Failed to build the plugin package')
if sync_github:
try:
git_result = await self.ap.plugin_connector.git_sync_plugin(
author,
plugin_name,
repo_url=str(data.get('repo_url') or ''),
token=str(data.get('token') or ''),
branch=str(data.get('branch') or ''),
commit_message=str(data.get('commit_message') or ''),
manifest_overrides=overrides,
)
except Exception as exc:
self.ap.logger.warning('Git sync failed for %s/%s: %s', author, plugin_name, exc)
return self.http_status(400, -1, f'Git sync failed: {exc}')
access_token = await _space_access_token_for_workspace(request_context)
if not access_token:
return self.http_status(
400,
-1,
'Please connect a LangBot Space account before uploading plugins',
)
space_service = getattr(self.ap, 'space_service', None)
if space_service is not None:
token_valid, creator_profile = await space_service.get_creator_profile(access_token)
if not token_valid:
return self.http_status(401, -1, 'Space login session has expired. Please re-authenticate.')
if not creator_profile or not creator_profile.get('slug'):
return self.http_status(
400, -1, 'Please set up a creator profile on Space before uploading plugins.'
)
creator_slug = creator_profile.get('slug')
if author and creator_slug and author.lower() != creator_slug.lower():
return self.http_status(
400,
-1,
f"Plugin author '{author}' does not match your Space creator slug '@{creator_slug}'.",
)
try:
submission = await self.ap.space_service.publish_plugin(
access_token,
package_bytes,
filename=package_result.get('filename') or 'plugin.lbpkg',
changelog=str(data.get('changelog') or ''),
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
quart.g.operation_log_resource_id = f'{author}/{plugin_name}'
return self.success(
data={
'submission': submission,
'git': git_result,
'filename': package_result.get('filename', ''),
}
)
@self.route(
'/<author>/<plugin_name>/space-upload/git-sync',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Synchronise only the debug plugin's working directory to GitHub."""
await self._require_authenticated_plugin_runtime_context(request_context)
try:
data = await quart.request.get_json(silent=True) or {}
except Exception:
data = {}
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
overrides = data.get('manifest_overrides') or {}
if not isinstance(overrides, dict):
return self.http_status(400, -1, 'manifest_overrides must be an object')
try:
git_result = await self.ap.plugin_connector.git_sync_plugin(
author,
plugin_name,
repo_url=str(data.get('repo_url') or ''),
token=str(data.get('token') or ''),
branch=str(data.get('branch') or ''),
commit_message=str(data.get('commit_message') or ''),
manifest_overrides=overrides,
)
except Exception as exc:
self.ap.logger.warning('Git sync failed for %s/%s: %s', author, plugin_name, exc)
return self.http_status(400, -1, str(exc))
return self.success(data={'git': git_result})
@self.route(
'/<author>/<plugin_name>/upgrade',
methods=['POST'],
@@ -616,15 +868,17 @@ class PluginsRouterGroup(group.RouterGroup):
await self._require_authenticated_plugin_runtime_context(request_context)
try:
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
except ValueError as exc:
if not icon_data or not icon_data.get('plugin_icon_base64'):
return quart.Response('Icon not found', status=404)
except (ValueError, ActionCallError) as exc:
# A plugin that is not installed for this Workspace is a missing
# resource, not a server fault: the WebUI asks for icons by plugin
# identity and must be able to fall back silently.
if _is_plugin_not_installed(exc):
if isinstance(exc, ActionCallError) or _is_plugin_not_installed(exc):
return quart.Response('Icon not found', status=404)
raise
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
return quart.Response(icon_bytes, mimetype=icon_data['mime_type'])
return quart.Response(icon_bytes, mimetype=icon_data.get('mime_type', 'image/svg+xml'))
@self.route(
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
@@ -642,7 +896,10 @@ class PluginsRouterGroup(group.RouterGroup):
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
try:
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
except (ValueError, ActionCallError):
return quart.Response('Asset not found', status=404)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64'])
@@ -654,14 +911,15 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str) -> quart.Response:
await self._require_public_plugin_runtime_context()
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
icon_base64 = icon_data['plugin_icon_base64']
mime_type = icon_data['mime_type']
icon_data = await asyncio.to_thread(base64.b64decode, icon_base64)
return quart.Response(icon_data, mimetype=mime_type)
try:
await self._require_public_plugin_runtime_context()
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
if not icon_data or not icon_data.get('plugin_icon_base64'):
return quart.Response('Icon not found', status=404)
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
return quart.Response(icon_bytes, mimetype=icon_data.get('mime_type', 'image/svg+xml'))
except (ValueError, ActionCallError, WorkspaceNotFoundError):
return quart.Response('Icon not found', status=404)
@self.route(
'/<author>/<plugin_name>/assets/<path:filepath>',
@@ -669,20 +927,23 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
await self._require_public_plugin_runtime_context()
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
try:
await self._require_public_plugin_runtime_context()
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_bytes = await asyncio.to_thread(
base64.b64decode,
asset_data['asset_base64'],
)
mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type)
except (ValueError, ActionCallError, WorkspaceNotFoundError):
return quart.Response('Asset not found', status=404)
asset_bytes = await asyncio.to_thread(
base64.b64decode,
asset_data['asset_base64'],
)
mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type)
# CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'):
@@ -59,6 +59,7 @@ SYSTEM_CAPABILITY_OPERATIONS = (
'plugin.install.marketplace',
'plugin.install.local',
'plugin.upgrade',
'plugin.space_upload',
'plugin.get',
'plugin.list',
'plugin.config.get',
@@ -227,7 +228,10 @@ class SystemRouterGroup(group.RouterGroup):
'enable_marketplace', True
),
'cloud_service_url': (
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')
self.ap.instance_config.data.get('space', {}).get(
'web_url',
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app'),
)
),
'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
+79
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections import OrderedDict
from langbot.pkg.utils import httpclient
import aiohttp
import typing
import datetime
import time
@@ -202,6 +203,33 @@ class SpaceService:
return None
return await self.get_user_info_raw(access_token)
async def get_creator_profile(self, access_token: str) -> typing.Tuple[bool, dict | None]:
"""Fetch the authenticated user's Creator Profile from Space.
Returns (is_token_valid, creator_dict_or_none).
"""
if not access_token:
return False, None
space_url = self._get_space_config()['url'].rstrip('/')
session = httpclient.get_session()
try:
async with session.get(
f'{space_url}/api/v1/creator/profile',
headers={'Authorization': f'Bearer {access_token}'},
) as response:
if response.status == 401:
return False, None
if response.status != 200:
return True, None
payload = await httpclient.read_json_limited(response)
if payload.get('code') != 0:
return True, None
data = payload.get('data') or {}
return True, data.get('creator')
except Exception:
self.ap.logger.exception('Failed to fetch creator profile from Space')
return True, None
async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
"""Get Space credits for user with caching (60s TTL)"""
now = time.time()
@@ -285,6 +313,57 @@ class SpaceService:
models.append(selection)
return [SpaceModelSelection.model_validate(model) for model in models]
# === Plugin publishing ===
async def publish_plugin(
self,
access_token: str,
package_bytes: bytes,
*,
filename: str = 'plugin.lbpkg',
changelog: str = '',
) -> typing.Dict:
"""Publish a built ``.lbpkg`` to the LangBot Space marketplace.
Mirrors the CLI's ``POST /api/v1/marketplace/plugins/publish`` contract
(multipart ``file`` + ``changelog``). The caller supplies a valid Space
access token resolved from the Workspace owner.
"""
if not access_token:
raise ValueError('A LangBot Space account is required to publish plugins')
space_url = self._get_space_config()['url'].rstrip('/')
form = aiohttp.FormData()
form.add_field('changelog', changelog or '')
form.add_field(
'file',
package_bytes,
filename=filename,
content_type='application/octet-stream',
)
session = httpclient.get_session()
async with session.post(
f'{space_url}/api/v1/marketplace/plugins/publish',
data=form,
headers={'Authorization': f'Bearer {access_token}'},
) as response:
if response.status != 200:
try:
payload = await httpclient.read_json_limited(response)
msg = payload.get('msg') or payload.get('message') or ''
except Exception:
msg = ''
if not msg:
msg = await httpclient.read_text_limited(response)
raise ValueError(msg or 'Failed to publish plugin')
data = await httpclient.read_json_limited(response)
if data.get('code') != 0:
raise ValueError(data.get('msg') or 'Failed to publish plugin')
return data.get('data', {})
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
"""Resolve Space's first ranked chat model to a local Workspace model."""
selection = await self.get_model_selection('chat')