diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 4bce79869..950a8954d 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -165,6 +165,16 @@ except Exception: pass +def _is_plugin_not_installed(exc: BaseException) -> bool: + """True when the connector reports a plugin absent from the resolved Workspace. + + The connector raises a plain ``ValueError`` for this case, so resource routes + answer 404 instead of surfacing a 500 the WebUI would have to swallow. + """ + + return 'is not installed in this workspace' in str(exc).lower() + + def _normalize_plugin_asset_path(filepath: str) -> str | None: filepath = filepath.replace('\\', '/') if filepath.startswith('/'): @@ -596,7 +606,15 @@ class PluginsRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> quart.Response: await self._require_authenticated_plugin_runtime_context(request_context) - icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + try: + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + except ValueError 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): + 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']) diff --git a/tests/unit_tests/plugin/test_plugin_file_transfer_root.py b/tests/unit_tests/plugin/test_plugin_file_transfer_root.py new file mode 100644 index 000000000..fad2751ba --- /dev/null +++ b/tests/unit_tests/plugin/test_plugin_file_transfer_root.py @@ -0,0 +1,51 @@ +"""The host must share the Runtime's plugin file-transfer root in shared deployments. + +A mismatch leaves plugin icons and assets failing with +"Invalid file transfer capability" because the SDK binds one directory inode per +process (mode 0700) and picks the default from the runtime profile. +""" + +from __future__ import annotations + +import os +from importlib import import_module +from types import SimpleNamespace + +from langbot_plugin.runtime.io.handler import SHARED_WORKER_FILE_STORAGE_DIR +from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV + + +def connector_module(): + import_module('langbot.pkg.core.app') + return import_module('langbot.pkg.plugin.connector') + + +def align(profile: str) -> None: + connector = connector_module() + connector.PluginRuntimeConnector._align_plugin_file_transfer_root( + SimpleNamespace(runtime_profile=profile) + ) + + +def test_shared_profile_pins_the_runtime_transfer_root(monkeypatch): + monkeypatch.delenv(PLUGIN_FILE_STORAGE_DIR_ENV, raising=False) + + align('shared') + + assert os.environ[PLUGIN_FILE_STORAGE_DIR_ENV] == SHARED_WORKER_FILE_STORAGE_DIR + + +def test_explicit_operator_value_is_never_overridden(monkeypatch): + monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, '/var/lib/custom-transfer') + + align('shared') + + assert os.environ[PLUGIN_FILE_STORAGE_DIR_ENV] == '/var/lib/custom-transfer' + + +def test_oss_profile_leaves_the_default_alone(monkeypatch): + monkeypatch.delenv(PLUGIN_FILE_STORAGE_DIR_ENV, raising=False) + + align('oss_dev') + + assert PLUGIN_FILE_STORAGE_DIR_ENV not in os.environ