diff --git a/dev-docs/architecture/application-api.md b/dev-docs/architecture/application-api.md index 3734e5ea5..3eef912e2 100644 --- a/dev-docs/architecture/application-api.md +++ b/dev-docs/architecture/application-api.md @@ -101,8 +101,9 @@ consumer's context from escaping into another consumer's task. ## Scope and adoption -This is the full **existing Workspace and execution business capability** -foundation. Instance administration, account login/bootstrap, public ingress, +This foundation covers the Workspace and execution business capabilities listed +in its explicit catalogs. Plugin publishing to Space and Git synchronization +remain in their existing HTTP routes. Instance administration, account login/bootstrap, public ingress, OAuth callbacks, transport assets and internal Runtime handshake/chunk/ping messages remain with their owning boundaries. Composite HTTP response layouts (such as a combined monitoring page) are consumer projections of these diff --git a/src/langbot/pkg/api/application/plugin_operations.py b/src/langbot/pkg/api/application/plugin_operations.py index 0777cbfb6..a53708701 100644 --- a/src/langbot/pkg/api/application/plugin_operations.py +++ b/src/langbot/pkg/api/application/plugin_operations.py @@ -14,6 +14,7 @@ from urllib.parse import urlparse import httpx import sqlalchemy +from langbot_plugin.entities.io.errors import ActionCallError from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from pydantic import Field @@ -232,13 +233,15 @@ async def icon(ap, context, payload): await _fence(ap, context) try: result = await ap.plugin_connector.get_plugin_icon(payload.author, payload.plugin_name) - except ValueError as exc: - if 'is not installed in this workspace' in str(exc).lower(): + except (ValueError, ActionCallError) as exc: + if isinstance(exc, ActionCallError) or 'is not installed in this workspace' in str(exc).lower(): raise WorkspaceNotFoundError('Icon not found') from exc raise + if not result or not result.get('plugin_icon_base64'): + raise WorkspaceNotFoundError('Icon not found') return { 'content': await asyncio.to_thread(base64.b64decode, result['plugin_icon_base64']), - 'mime_type': result['mime_type'], + 'mime_type': result.get('mime_type', 'image/svg+xml'), } @@ -247,7 +250,10 @@ async def asset(ap, context, payload): path = normalize_plugin_asset_path(payload.filepath) if path is None: raise WorkspaceNotFoundError('Asset not found') - result = await ap.plugin_connector.get_plugin_assets(payload.author, payload.plugin_name, path) + try: + result = await ap.plugin_connector.get_plugin_assets(payload.author, payload.plugin_name, path) + except (ValueError, ActionCallError) as exc: + raise WorkspaceNotFoundError('Asset not found') from exc if not result.get('asset_base64'): raise WorkspaceNotFoundError('Asset not found') return { diff --git a/tests/unit_tests/api/test_application_plugins.py b/tests/unit_tests/api/test_application_plugins.py index 997fcaf0d..78d601575 100644 --- a/tests/unit_tests/api/test_application_plugins.py +++ b/tests/unit_tests/api/test_application_plugins.py @@ -7,9 +7,11 @@ from unittest.mock import AsyncMock, Mock from zipfile import ZipFile import pytest +from langbot_plugin.entities.io.errors import ActionCallError from pydantic import TypeAdapter, ValidationError from langbot.pkg.api.application import plugin_operations as ops +from langbot.pkg.api.application.errors import error_from_exception from langbot.pkg.api.context import ExecutionContext from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError, WorkspaceNotFoundError @@ -116,6 +118,42 @@ async def test_asset_path_rejects_escape_before_runtime_read_and_returns_exact_b assert ap.plugin_connector.get_plugin_assets.await_args.args == ('a', 'b', 'assets/image.png') +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['icon', 'asset']) +@pytest.mark.parametrize( + 'error', [ActionCallError('Plugin asset unavailable'), ValueError('Plugin a/b is not installed in this Workspace')] +) +async def test_missing_plugin_media_maps_to_not_found(operation, error): + ap = app() + ap.plugin_connector.get_plugin_icon = AsyncMock(side_effect=error) + ap.plugin_connector.get_plugin_assets = AsyncMock(side_effect=error) + payload = ops.AssetInput(author='a', plugin_name='b', filepath='image.png') + with pytest.raises(WorkspaceNotFoundError) as failure: + await getattr(ops, operation)(ap, CONTEXT, payload) + result = error_from_exception(failure.value) + assert (result.code, result.status_code) == ('resource_not_found', 404) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('result', [None, {}, {'plugin_icon_base64': ''}]) +async def test_empty_plugin_icon_is_not_found(result): + ap = app() + ap.plugin_connector.get_plugin_icon = AsyncMock(return_value=result) + with pytest.raises(WorkspaceNotFoundError): + await ops.icon(ap, CONTEXT, ops.PluginInput(author='a', plugin_name='b')) + + +@pytest.mark.asyncio +async def test_plugin_icon_defaults_to_svg_and_preserves_other_validation_errors(): + ap = app() + ap.plugin_connector.get_plugin_icon = AsyncMock(return_value={'plugin_icon_base64': 'PHN2Zy8+'}) + payload = ops.PluginInput(author='a', plugin_name='b') + assert await ops.icon(ap, CONTEXT, payload) == {'content': b'', 'mime_type': 'image/svg+xml'} + ap.plugin_connector.get_plugin_icon.side_effect = ValueError('Invalid plugin identity') + with pytest.raises(ValueError, match='Invalid plugin identity'): + await ops.icon(ap, CONTEXT, payload) + + @pytest.mark.asyncio async def test_config_files_are_workspace_scoped_and_size_checked(): ap = app()