diff --git a/src/langbot/pkg/api/application/__init__.py b/src/langbot/pkg/api/application/__init__.py index 459c7c9de..33223e073 100644 --- a/src/langbot/pkg/api/application/__init__.py +++ b/src/langbot/pkg/api/application/__init__.py @@ -2,5 +2,15 @@ from .api import ApplicationAPI from .contracts import ApplicationAPIError, ApplicationAPIException, Input, Operation +from .tool_grants import get_workspace_operations_catalog, validate_grant_change + +__all__ = [ + 'ApplicationAPI', + 'ApplicationAPIError', + 'ApplicationAPIException', + 'Input', + 'Operation', + 'get_workspace_operations_catalog', + 'validate_grant_change', +] -__all__ = ['ApplicationAPI', 'ApplicationAPIError', 'ApplicationAPIException', 'Input', 'Operation'] diff --git a/src/langbot/pkg/api/application/tool_grants.py b/src/langbot/pkg/api/application/tool_grants.py new file mode 100644 index 000000000..a99cbf734 --- /dev/null +++ b/src/langbot/pkg/api/application/tool_grants.py @@ -0,0 +1,186 @@ +"""Processor workspace operations authorization and catalog.""" + +from __future__ import annotations + +from typing import Any, Iterable + +from ..authz import has_permission +from ..context import PrincipalType, RequestContext +from .contracts import ApplicationAPIError, ApplicationAPIException, Operation +from .operation_docs import DOMAINS, get_operation_doc + + +def validate_grant_change( + ap: Any, + editor_context: RequestContext, + old_ids: Iterable[str] | None, + new_ids: Iterable[str] | None, +) -> list[str]: + """Validate additions to granted workspace operations for a processor. + + Existing grants are preserved without re-validation so low-privileged members + can update other processor fields without losing existing high-privilege grants. + Removals are unrestricted. + """ + if new_ids is None: + new_ids = [] + if old_ids is None: + old_ids = [] + + if not isinstance(new_ids, (list, tuple, set, frozenset)): + raise ApplicationAPIException( + ApplicationAPIError( + code='invalid_input', + message='Workspace operations must be a list of strings', + status_code=400, + details={'operations': []}, + ) + ) + + for item in new_ids: + if not isinstance(item, str): + raise ApplicationAPIException( + ApplicationAPIError( + code='invalid_input', + message='Workspace operation IDs must be strings', + status_code=400, + details={'operations': [item]}, + ) + ) + + for item in old_ids: + if not isinstance(item, str): + raise ApplicationAPIException( + ApplicationAPIError( + code='invalid_input', + message='Workspace operation IDs must be strings', + status_code=400, + details={'operations': [item]}, + ) + ) + + normalized_new = sorted(set(new_ids)) + normalized_old = set(old_ids) + added_ids = [op for op in normalized_new if op not in normalized_old] + + if not added_ids: + return normalized_new + + app_api = getattr(ap, 'application_api', None) + if app_api is None: + from .api import ApplicationAPI + + app_api = ApplicationAPI(ap) + + operations_map: dict[str, Operation] = getattr(app_api, '_operations', {}) + + invalid_ids: list[str] = [] + denied_ids: list[str] = [] + + for op_id in added_ids: + spec = operations_map.get(op_id) + if spec is None: + invalid_ids.append(op_id) + continue + + if spec.streaming: + invalid_ids.append(op_id) + continue + + if spec.principals is not None and PrincipalType.PROCESSOR not in spec.principals: + invalid_ids.append(op_id) + continue + + doc = get_operation_doc(spec.operation_id, side_effect=spec.side_effect) + if doc.exposure != 'all': + invalid_ids.append(op_id) + continue + + if ( + editor_context is None + or not hasattr(editor_context, 'workspace') + or not has_permission(editor_context, spec.permission) + ): + denied_ids.append(op_id) + + if invalid_ids: + raise ApplicationAPIException( + ApplicationAPIError( + code='invalid_input', + message=f'Invalid workspace operations: {invalid_ids}', + status_code=400, + details={'operations': invalid_ids, 'invalid_operations': invalid_ids}, + ) + ) + + if denied_ids: + raise ApplicationAPIException( + ApplicationAPIError( + code='permission_denied', + message=f'Permission denied for workspace operations: {denied_ids}', + status_code=403, + details={'operations': denied_ids, 'denied_operations': denied_ids}, + ) + ) + + return normalized_new + + +def get_workspace_operations_catalog(ap: Any, context: Any) -> dict[str, Any]: + """Return the operations catalog and domains metadata for processor tool granting.""" + app_api = getattr(ap, 'application_api', None) + if app_api is None: + from .api import ApplicationAPI + + app_api = ApplicationAPI(ap) + + operations_map: dict[str, Operation] = getattr(app_api, '_operations', {}) + ops: list[dict[str, Any]] = [] + seen_domains: set[str] = set() + + for _op_id, spec in sorted(operations_map.items()): + doc = get_operation_doc(spec.operation_id, side_effect=spec.side_effect) + if doc.exposure != 'all': + continue + + can_run = (not spec.streaming) and (spec.principals is None or PrincipalType.PROCESSOR in spec.principals) + has_perm = ( + has_permission(context, spec.permission) if context is not None and hasattr(context, 'workspace') else False + ) + grantable = bool(can_run and has_perm) + + perm = spec.permission + perm_str = perm.value if hasattr(perm, 'value') else str(perm) + + ops.append( + { + 'id': spec.operation_id, + 'domain': doc.domain, + 'label': doc.label, + 'summary': doc.summary, + 'risk': doc.risk, + 'permission': perm_str, + 'grantable': grantable, + } + ) + seen_domains.add(doc.domain) + + domains_list = [ + { + 'domain': d, + 'label': DOMAINS.get(d, {}).get('label', {'zh_Hans': d, 'en_US': d.capitalize()}), + 'summary': DOMAINS.get(d, {}).get('summary', ''), + } + for d in sorted(seen_domains) + ] + + return { + 'workspace_operations': ops, + 'domains': domains_list, + } + + +__all__ = [ + 'get_workspace_operations_catalog', + 'validate_grant_change', +] diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py index ed2c988c9..0d43da71d 100644 --- a/src/langbot/pkg/api/http/controller/groups/agents.py +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -9,6 +9,7 @@ from .....agent.runner.errors import ( RunnerNotFoundError, RunnerProtocolError, ) +from ....application.contracts import ApplicationAPIException from ...authz import Permission, require_permission from ...context import RequestContext from .. import group @@ -91,6 +92,8 @@ class AgentsRouterGroup(group.RouterGroup): require_permission(request_context, Permission.RESOURCE_MANAGE) try: created = await self.ap.agent_service.create_agent(request_context, json_data) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data=created) @@ -170,6 +173,8 @@ class AgentsRouterGroup(group.RouterGroup): json_data = await quart.request.json try: await self.ap.agent_service.update_agent(request_context, agent_uuid, json_data) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 33bc79ac8..4e6597aa2 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -2,6 +2,7 @@ from __future__ import annotations import quart +from .....application.contracts import ApplicationAPIException from ....authz import Permission, has_permission from ....context import RequestContext from ......operation_trace import service as settings_service @@ -53,6 +54,8 @@ class PipelinesRouterGroup(group.RouterGroup): pipeline_data, default=create_as_default, ) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'uuid': pipeline_uuid}) @@ -102,6 +105,8 @@ class PipelinesRouterGroup(group.RouterGroup): pipeline_uuid, json_data, ) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as exc: return self.http_status(400, -1, str(exc)) changes = settings_service.changed_fields( @@ -127,6 +132,8 @@ class PipelinesRouterGroup(group.RouterGroup): try: new_uuid = await self.ap.pipeline_service.copy_pipeline(request_context, pipeline_uuid) return self.success(data={'uuid': new_uuid}) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as e: return self.http_status(400, -1, str(e)) @@ -223,6 +230,7 @@ class PipelinesRouterGroup(group.RouterGroup): 'enable_all_mcp_servers': 'enable_all_mcp_servers', 'enable_all_skills': 'enable_all_skills', 'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled', + 'workspace_operations': 'workspace_operations', }.items() if field in json_data }, @@ -245,7 +253,10 @@ class PipelinesRouterGroup(group.RouterGroup): enable_all_skills=json_data.get('enable_all_skills', True), bound_mcp_resources=json_data.get('bound_mcp_resources'), mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'), + workspace_operations=json_data.get('workspace_operations'), ) + except ApplicationAPIException as exc: + return self.http_status(exc.error.status_code, exc.error.code, exc.error.message) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success() diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 442e1dcf7..4c8e7d85a 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -42,6 +42,7 @@ from ....agent.runner.platform_tools import ( ) from ....entity.persistence import agent as persistence_agent from ....workspace.errors import WorkspaceNotFoundError +from ...application.tool_grants import get_workspace_operations_catalog, validate_grant_change from ..context import ExecutionContext, RequestContext from .tenant import TenantContext, require_workspace_uuid, scope_statement @@ -87,11 +88,14 @@ class AgentService: use_cache=False, ) ] + ws_catalog = get_workspace_operations_catalog(self.ap, context) return { 'event_processors': event_processors, 'runner_config': ai_metadata, 'platform_tools': platform_tool_catalog(), 'host_tools': host_tools, + 'workspace_operations': ws_catalog['workspace_operations'], + 'domains': ws_catalog['domains'], 'kinds': [ { 'name': AGENT_KIND_AGENT, @@ -458,6 +462,9 @@ class AgentService: config['runner_config'][runner_id], ) patterns = agent_data.get('supported_event_patterns', AGENT_DEFAULT_EVENT_PATTERNS) + if isinstance(config, dict) and 'allowed_workspace_operations' in config: + new_ops = config['allowed_workspace_operations'] + config['allowed_workspace_operations'] = validate_grant_change(self.ap, context, [], new_ops) new_uuid = str(uuid.uuid4()) values = { 'workspace_uuid': workspace_uuid, @@ -482,6 +489,9 @@ class AgentService: await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data) return + old_config = existing_agent.config if isinstance(existing_agent.config, dict) else {} + old_ops = old_config.get('allowed_workspace_operations', []) + update_data = { field: agent_data[field] for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns') @@ -500,6 +510,15 @@ class AgentService: update_data['component_ref'] = runner_id if existing_agent.kind == AGENT_KIND_AGENT: await self._validate_runner_for_agent(context, runner_id) + + if 'config' in update_data and isinstance(update_data['config'], dict): + new_config = update_data['config'] + if 'allowed_workspace_operations' in new_config: + new_ops = new_config['allowed_workspace_operations'] + new_config['allowed_workspace_operations'] = validate_grant_change(self.ap, context, old_ops, new_ops) + else: + new_config['allowed_workspace_operations'] = old_ops + result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.update(persistence_agent.Agent) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 934cf97f9..650adad60 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -13,6 +13,7 @@ from ....pipeline.extension_preferences import ( validate_extension_preferences, ) from ....workspace.errors import WorkspaceNotFoundError +from ...application.tool_grants import get_workspace_operations_catalog, validate_grant_change from .secrets import redact_secrets, restore_secret_placeholders from .tenant import TenantContext, require_workspace_uuid, scope_statement @@ -206,6 +207,11 @@ class PipelineService: 'mcp_resources': [], 'mcp_resource_agent_read_enabled': True, } + else: + prefs = pipeline_data['extensions_preferences'] + if isinstance(prefs, dict) and 'workspace_operations' in prefs: + new_ops = prefs['workspace_operations'] + prefs['workspace_operations'] = validate_grant_change(self.ap, context, [], new_ops) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data) @@ -248,6 +254,18 @@ class PipelineService: ) RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) if 'extensions_preferences' in pipeline_data: + current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True) + if current_pipeline is None: + raise WorkspaceNotFoundError('Pipeline not found') + old_prefs = current_pipeline.get('extensions_preferences', {}) if isinstance(current_pipeline, dict) else {} + old_ops = old_prefs.get('workspace_operations', []) if isinstance(old_prefs, dict) else [] + new_prefs = pipeline_data['extensions_preferences'] + if isinstance(new_prefs, dict): + if 'workspace_operations' in new_prefs: + new_ops = new_prefs['workspace_operations'] + new_prefs['workspace_operations'] = validate_grant_change(self.ap, context, old_ops, new_ops) + else: + new_prefs['workspace_operations'] = old_ops self._validate_extension_preferences(pipeline_data['extensions_preferences']) result = await self.ap.persistence_mgr.execute_async( @@ -322,6 +340,9 @@ class PipelineService: # Create new pipeline data new_uuid = str(uuid.uuid4()) + new_prefs = normalize_extension_preferences(original_pipeline.extensions_preferences) + original_ops = new_prefs.get('workspace_operations', []) + new_prefs['workspace_operations'] = validate_grant_change(self.ap, context, [], original_ops) new_pipeline_data = { 'uuid': new_uuid, 'workspace_uuid': workspace_uuid, @@ -331,7 +352,7 @@ class PipelineService: 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), 'config': original_pipeline.config.copy() if original_pipeline.config else {}, 'is_default': False, - 'extensions_preferences': normalize_extension_preferences(original_pipeline.extensions_preferences), + 'extensions_preferences': new_prefs, } # Insert the new pipeline @@ -352,6 +373,7 @@ class PipelineService: if self.ap.plugin_connector.is_enable_plugin: await self.ap.plugin_connector.require_workspace_context(context) plugins = await self.ap.plugin_connector.list_plugins(component_kinds=['Command', 'EventListener', 'Tool']) + catalog = get_workspace_operations_catalog(self.ap, context) prefs = pipeline.get('extensions_preferences', {}) return { 'enable_all_plugins': prefs.get('enable_all_plugins', True), @@ -365,6 +387,9 @@ class PipelineService: 'mcp_resource_agent_read_enabled': prefs.get('mcp_resource_agent_read_enabled', True), 'bound_skills': prefs.get('skills', []), 'available_skills': await self.ap.skill_service.list_skills(context), + 'workspace_operations': prefs.get('workspace_operations', []), + 'available_workspace_operations': catalog['workspace_operations'], + 'domains': catalog['domains'], } async def update_pipeline_extensions( @@ -379,6 +404,7 @@ class PipelineService: enable_all_skills: bool = True, bound_mcp_resources: list[dict] = None, mcp_resource_agent_read_enabled: bool | None = None, + workspace_operations: list[str] | None = None, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" workspace_uuid = require_workspace_uuid(context) @@ -436,6 +462,14 @@ class PipelineService: if bound_mcp_resources is not None: extensions_preferences['mcp_resources'] = bound_mcp_resources + old_ops = extensions_preferences.get('workspace_operations', []) + if workspace_operations is not None: + extensions_preferences['workspace_operations'] = validate_grant_change( + self.ap, context, old_ops, workspace_operations + ) + else: + extensions_preferences['workspace_operations'] = old_ops + await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.update(persistence_pipeline.LegacyPipeline) diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 967a964ca..4f69749e6 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -149,6 +149,7 @@ class LangBotMCPServer: enable_all_mcp_servers: bool, enable_all_skills: bool, mcp_resource_agent_read_enabled: bool, + workspace_operations: list[str] | None = None, ) -> str: context = _authorized(Permission.RESOURCE_MANAGE) require_permission(context, Permission.RESOURCE_VIEW) @@ -163,6 +164,7 @@ class LangBotMCPServer: enable_all_skills, bound_mcp_resources, mcp_resource_agent_read_enabled, + workspace_operations=workspace_operations, ) return _dump(await ap.pipeline_service.get_pipeline_extensions(context, pipeline_uuid)) diff --git a/src/langbot/pkg/pipeline/extension_preferences.py b/src/langbot/pkg/pipeline/extension_preferences.py index 458700f15..2366acebf 100644 --- a/src/langbot/pkg/pipeline/extension_preferences.py +++ b/src/langbot/pkg/pipeline/extension_preferences.py @@ -44,6 +44,7 @@ def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]: 'mcp_servers': [], 'skills': [], 'mcp_resources': [], + 'workspace_operations': [], } normalized = dict(value) @@ -77,6 +78,10 @@ def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]: normalized['mcp_resources'] = list(mcp_resources) if mcp_resources_are_valid else [] if not mcp_resources_are_valid: normalized['mcp_resource_agent_read_enabled'] = False + + workspace_operations = value.get('workspace_operations', []) + ops_are_valid = isinstance(workspace_operations, list) and all(_valid_name(op) for op in workspace_operations) + normalized['workspace_operations'] = list(workspace_operations) if ops_are_valid else [] return normalized @@ -127,6 +132,14 @@ def validate_extension_preferences( 'an object', context, ) + _validate_list_field( + value, + 'workspace_operations', + aliases.get('workspace_operations', 'workspace_operations'), + _valid_name, + 'a non-empty string', + context, + ) return value diff --git a/tests/unit_tests/api/test_tool_grants.py b/tests/unit_tests/api/test_tool_grants.py new file mode 100644 index 000000000..deaadcab2 --- /dev/null +++ b/tests/unit_tests/api/test_tool_grants.py @@ -0,0 +1,251 @@ +"""Unit tests for processor workspace operations authorization and catalog (LANG-226).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.application.api import ApplicationAPI +from langbot.pkg.api.application.contracts import ApplicationAPIException +from langbot.pkg.api.application.tool_grants import ( + get_workspace_operations_catalog, + validate_grant_change, +) +from langbot.pkg.api.authz import Permission, permissions_for_role +from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext +from langbot.pkg.api.http.service.pipeline import PipelineService +from langbot.pkg.pipeline.extension_preferences import normalize_extension_preferences + + +def make_context(role: str, permissions: frozenset[str] | None = None) -> RequestContext: + if permissions is None: + permissions = permissions_for_role(role) + return RequestContext( + instance_uuid='test-instance', + placement_generation=1, + request_id='req-1', + auth_type='cookie', + principal=PrincipalContext( + principal_type=PrincipalType.ACCOUNT, + account_uuid='acc-1', + ), + workspace=WorkspaceContext( + workspace_uuid='ws-1', + membership_uuid='mem-1', + role=role, + permissions=permissions, + ), + ) + + +@pytest.fixture +def test_host(monkeypatch): + monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US')) + host = SimpleNamespace( + logger=Mock(), + workspace_service=SimpleNamespace( + get_execution_binding=AsyncMock( + return_value=SimpleNamespace(instance_uuid='test-instance', placement_generation=1) + ) + ), + persistence_mgr=SimpleNamespace(), + ) + host.application_api = ApplicationAPI(host) + return host + + +# 1. Admin 可以勾选 provider_secret.manage 级别的操作,member 不能 +def test_admin_can_grant_provider_secret_manage_and_member_cannot(test_host): + admin_ctx = make_context('admin') + # model.llm.create requires provider_secret.manage and has exposure == 'all' + granted = validate_grant_change(test_host, admin_ctx, [], ['model.llm.create']) + assert granted == ['model.llm.create'] + + member_ctx = make_context('viewer') + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, member_ctx, [], ['model.llm.create']) + assert exc_info.value.error.code == 'permission_denied' + assert exc_info.value.error.status_code == 403 + assert 'model.llm.create' in exc_info.value.error.details['operations'] + + +# 2. Member 修改一个已经包含高权限勾选项的流水线的其他字段时,可以保存 +def test_member_can_save_pipeline_with_existing_high_privilege_grants(test_host): + # Member only has RESOURCE_VIEW and RESOURCE_MANAGE, NOT provider_secret.manage + member_ctx = make_context( + 'operator', + frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}), + ) + + old_ids = ['model.llm.create', 'bot.list'] + # Same operations kept, no new operations added + new_ids = ['bot.list', 'model.llm.create'] + + result = validate_grant_change(test_host, member_ctx, old_ids, new_ids) + assert result == ['bot.list', 'model.llm.create'] + + # Member removing one operation also succeeds + reduced = validate_grant_change(test_host, member_ctx, old_ids, ['bot.list']) + assert reduced == ['bot.list'] + + +# 3. Member 新增高权限勾选项时被拒绝 +def test_member_adding_high_privilege_grant_is_rejected(test_host): + member_ctx = make_context( + 'developer', + frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}), + ) + + old_ids = ['bot.list'] + # Tries to add model.llm.create which requires provider_secret.manage + new_ids = ['bot.list', 'model.llm.create'] + + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, member_ctx, old_ids, new_ids) + assert exc_info.value.error.code == 'permission_denied' + assert exc_info.value.error.status_code == 403 + assert 'model.llm.create' in exc_info.value.error.details['operations'] + + +# 4. 勾选 exposure != 'all' 的操作被拒绝 +def test_granting_non_all_exposure_or_invalid_operations_rejected(test_host): + admin_ctx = make_context('admin') + + # pipeline.migration.preview has exposure == 'assistant' + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, admin_ctx, [], ['pipeline.migration.preview']) + assert exc_info.value.error.code == 'invalid_input' + assert exc_info.value.error.status_code == 400 + assert 'pipeline.migration.preview' in exc_info.value.error.details['operations'] + + # agent.debug.stream is streaming / exposure == 'none' + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, admin_ctx, [], ['agent.debug.stream']) + assert exc_info.value.error.code == 'invalid_input' + assert exc_info.value.error.status_code == 400 + + # Non-existent operation + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, admin_ctx, [], ['unknown.nonexistent.op']) + assert exc_info.value.error.code == 'invalid_input' + + # Wildcard notation rejected + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, admin_ctx, [], ['bot.*']) + assert exc_info.value.error.code == 'invalid_input' + + # Non-string item rejected + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, admin_ctx, [], [123]) + assert exc_info.value.error.code == 'invalid_input' + + +# 5. 复制流水线时的权限检查 +@pytest.mark.asyncio +async def test_copy_pipeline_permission_check(test_host): + # Setup mock pipeline service + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace(), + instance_config=SimpleNamespace(data={'system': {'limitation': {'max_pipelines': -1}}}), + pipeline_mgr=SimpleNamespace(load_pipeline=AsyncMock()), + ver_mgr=SimpleNamespace(get_current_version=Mock(return_value='1.0.0')), + application_api=test_host.application_api, + ) + + original_pipeline = SimpleNamespace( + name='Source Pipeline', + description='Source description', + stages=['Stage1'], + config={}, + extensions_preferences={ + 'enable_all_plugins': True, + 'plugins': [], + 'workspace_operations': ['model.llm.create'], + }, + ) + + service = PipelineService(ap) + service.get_pipelines = AsyncMock(return_value=[]) + ap.persistence_mgr.execute_async = AsyncMock( + return_value=SimpleNamespace(first=Mock(return_value=original_pipeline)) + ) + service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-123', 'name': 'Source Pipeline (Copy)'}) + + # 1) Admin copies: succeeds + admin_ctx = make_context('admin') + copy_uuid = await service.copy_pipeline(admin_ctx, 'orig-uuid') + assert copy_uuid is not None + + # 2) Member without provider_secret.manage copies: raises ApplicationAPIException (permission_denied) + member_ctx = make_context( + 'operator', + frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}), + ) + with pytest.raises(ApplicationAPIException) as exc_info: + await service.copy_pipeline(member_ctx, 'orig-uuid') + assert exc_info.value.error.code == 'permission_denied' + assert exc_info.value.error.status_code == 403 + + +# 6. enable_all_plugins=True 时,工作区操作仍然是空的 +def test_enable_all_plugins_does_not_enable_workspace_operations(): + prefs = normalize_extension_preferences({'enable_all_plugins': True}) + assert prefs['enable_all_plugins'] is True + assert prefs['workspace_operations'] == [] + + prefs_with_ops = normalize_extension_preferences( + { + 'enable_all_plugins': True, + 'workspace_operations': ['bot.list'], + } + ) + assert prefs_with_ops['enable_all_plugins'] is True + assert prefs_with_ops['workspace_operations'] == ['bot.list'] + + +# 7. 目录接口与 grantable 标记 +def test_workspace_operations_catalog_and_grantable_flags(test_host): + admin_ctx = make_context('admin') + admin_catalog = get_workspace_operations_catalog(test_host, admin_ctx) + + ops = admin_catalog['workspace_operations'] + domains = admin_catalog['domains'] + + assert len(ops) > 0 + assert len(domains) > 0 + + # Every item has {id, domain, label, summary, risk, permission, grantable} + for item in ops: + assert 'id' in item + assert 'domain' in item + assert 'label' in item + assert 'summary' in item + assert 'risk' in item + assert 'permission' in item + assert 'grantable' in item + + # Admin has permission for provider_secret.manage ops + admin_secret_ops = [op for op in ops if op['permission'] == Permission.PROVIDER_SECRET_MANAGE.value] + assert len(admin_secret_ops) > 0 + for op in admin_secret_ops: + assert op['grantable'] is True + + # Member without provider_secret.manage sees grantable == False for secret ops + member_ctx = make_context('viewer') + member_catalog = get_workspace_operations_catalog(test_host, member_ctx) + member_secret_ops = [ + op + for op in member_catalog['workspace_operations'] + if op['permission'] == Permission.PROVIDER_SECRET_MANAGE.value + ] + for op in member_secret_ops: + assert op['grantable'] is False + + # Read-only operations requiring only RESOURCE_VIEW are grantable for viewer + viewer_view_ops = [ + op for op in member_catalog['workspace_operations'] if op['permission'] == Permission.RESOURCE_VIEW.value + ] + for op in viewer_view_ops: + assert op['grantable'] is True diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index cfb9656ea..b2a6e0272 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -21,7 +21,9 @@ import { Agent, AgentPlatformTool, ApiRespPluginSystemStatus, + DomainMeta, PluginTool, + WorkspaceOperationItem, } from '@/app/infra/entities/api'; import { PipelineConfigStage, @@ -44,12 +46,23 @@ import { CardTitle, } from '@/components/ui/card'; import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import AgentEventPatternPicker from './AgentEventPatternPicker'; import RunnerSelect from './RunnerSelect'; import GuidedTour, { GuidedTourStep, } from '@/app/home/components/guided-tour/GuidedTour'; import AgentApiToolPicker from './AgentApiToolPicker'; +import WorkspaceOperationsPicker from '@/app/home/components/workspace-operations/WorkspaceOperationsPicker'; const OTHER_TOOL_SCOPES = [ 'platform', @@ -147,6 +160,13 @@ function AgentFormComponent( const [hostTools, setHostTools] = useState([]); const [hostToolCatalogAvailable, setHostToolCatalogAvailable] = useState(true); + const [workspaceOperations, setWorkspaceOperations] = useState< + WorkspaceOperationItem[] + >([]); + const [domains, setDomains] = useState([]); + const [dangerousModalOpen, setDangerousModalOpen] = useState(false); + const pendingSaveValuesRef = useRef(null); + const pendingResolveRef = useRef<((value: boolean) => void) | null>(null); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); const [pluginStatusLoading, setPluginStatusLoading] = useState(true); @@ -171,6 +191,7 @@ function AgentFormComponent( supported_event_patterns: z.array(z.string()), allowed_platform_tools: z.array(z.string()), allowed_tools: z.array(z.string()), + allowed_workspace_operations: z.array(z.string()), }); type FormValues = z.infer; @@ -187,6 +208,7 @@ function AgentFormComponent( supported_event_patterns: ['*'], allowed_platform_tools: [], allowed_tools: [], + allowed_workspace_operations: [], }, }); const runnerInstallScope = `agent:${agentId}`; @@ -237,6 +259,8 @@ function AgentFormComponent( : []; setHostToolCatalogAvailable(hasHostToolCatalog); setHostTools(availableHostTools); + setWorkspaceOperations(metadata.workspace_operations || []); + setDomains(metadata.domains || []); const agent = resp.agent; const config = (agent.config ?? {}) as Record; const configuredHostTools = Array.isArray(config.allowed_tools) @@ -251,6 +275,13 @@ function AgentFormComponent( (name): name is string => typeof name === 'string', ) : []; + const configuredWorkspaceOperations = Array.isArray( + config.allowed_workspace_operations, + ) + ? config.allowed_workspace_operations.filter( + (name): name is string => typeof name === 'string', + ) + : []; const configuredEventPatterns = agent.supported_event_patterns ?? agent.capability?.supported_event_patterns; @@ -278,6 +309,7 @@ function AgentFormComponent( }), allowed_tools: configuredHostTools ?? availableHostTools.map((tool) => tool.name), + allowed_workspace_operations: configuredWorkspaceOperations, }; form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); @@ -639,6 +671,7 @@ function AgentFormComponent( runner, runner_config: values.runner_config ?? {}, allowed_platform_tools: values.allowed_platform_tools, + allowed_workspace_operations: values.allowed_workspace_operations, }; if (hostToolCatalogAvailable) { config.allowed_tools = values.allowed_tools; @@ -688,8 +721,26 @@ function AgentFormComponent( ], ); + const confirmAndSave = useCallback( + (values: FormValues): Promise => { + const hasDangerous = values.allowed_workspace_operations.some((opId) => { + const op = workspaceOperations.find((item) => item.id === opId); + return op?.risk === 'dangerous'; + }); + if (hasDangerous) { + return new Promise((resolve) => { + pendingSaveValuesRef.current = values; + pendingResolveRef.current = resolve; + setDangerousModalOpen(true); + }); + } + return saveValues(values); + }, + [saveValues, workspaceOperations], + ); + function handleSubmit(values: FormValues) { - void saveValues(values); + void confirmAndSave(values); } useImperativeHandle( @@ -721,14 +772,14 @@ function AgentFormComponent( if (isSavingRef.current) return false; const valid = await form.trigger(); if (!valid) return false; - return (await saveValues(form.getValues())) ?? false; + return (await confirmAndSave(form.getValues())) ?? false; }, }), [ + confirmAndSave, form, initialDataLoaded, loadFailed, - saveValues, hasUnsavedChangesRef, refreshDirty, ], @@ -892,6 +943,40 @@ function AgentFormComponent( )} /> +
+
+

+ {t('workspaceOperations.title')} +

+

+ {t('workspaceOperations.description')} +

+
+ ( + + + form.setValue( + 'allowed_workspace_operations', + next, + { + shouldDirty: true, + shouldValidate: true, + }, + ) + } + /> + + + )} + /> +
)} @@ -903,6 +988,48 @@ function AgentFormComponent( steps={runnerGuideSteps} testId="runner-setup-guide" /> + + + + + {t('workspaceOperations.dangerousConfirmTitle')} + + + {t('workspaceOperations.dangerousConfirmDescription')} + + + + { + setDangerousModalOpen(false); + pendingResolveRef.current?.(false); + pendingSaveValuesRef.current = null; + pendingResolveRef.current = null; + }} + > + {t('workspaceOperations.dangerousConfirmCancel')} + + { + setDangerousModalOpen(false); + if (pendingSaveValuesRef.current) { + const vals = pendingSaveValuesRef.current; + pendingSaveValuesRef.current = null; + const resolve = pendingResolveRef.current; + pendingResolveRef.current = null; + const success = await saveValues(vals); + resolve?.(success); + } + }} + > + {t('workspaceOperations.dangerousConfirmAction')} + + + + diff --git a/web/src/app/home/components/workspace-operations/WorkspaceOperationsPicker.tsx b/web/src/app/home/components/workspace-operations/WorkspaceOperationsPicker.tsx new file mode 100644 index 000000000..d77523b20 --- /dev/null +++ b/web/src/app/home/components/workspace-operations/WorkspaceOperationsPicker.tsx @@ -0,0 +1,439 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ChevronDown, + ChevronRight, + Search, + ShieldAlert, + HelpCircle, +} from 'lucide-react'; +import type { + WorkspaceOperationItem, + DomainMeta, +} from '@/app/infra/entities/api'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; + +export interface WorkspaceOperationsPickerProps { + operations: WorkspaceOperationItem[]; + domains?: DomainMeta[]; + value: string[]; + onChange: (value: string[]) => void; + disabled?: boolean; +} + +export default function WorkspaceOperationsPicker({ + operations, + domains = [], + value = [], + onChange, + disabled = false, +}: WorkspaceOperationsPickerProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(''); + const [openDomains, setOpenDomains] = useState>({}); + + const selectedSet = useMemo(() => new Set(value), [value]); + + // Map domain names to domain metadata + const domainMetaMap = useMemo(() => { + const map = new Map(); + for (const d of domains) { + map.set(d.domain, d); + } + return map; + }, [domains]); + + // Group operations by domain + const groupedOperations = useMemo(() => { + const groups = new Map(); + for (const op of operations) { + if (!groups.has(op.domain)) { + groups.set(op.domain, []); + } + groups.get(op.domain)!.push(op); + } + return groups; + }, [operations]); + + // Sorted domain list + const domainList = useMemo(() => { + return Array.from(groupedOperations.keys()).sort(); + }, [groupedOperations]); + + // Filtered operations based on search query + const filteredDomainGroups = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return domainList.map((domain) => ({ + domain, + operations: groupedOperations.get(domain) || [], + })); + } + + const result: Array<{ + domain: string; + operations: WorkspaceOperationItem[]; + }> = []; + + for (const domain of domainList) { + const ops = groupedOperations.get(domain) || []; + const matchingOps = ops.filter((op) => { + const idMatch = op.id.toLowerCase().includes(query); + const zhLabelMatch = op.label?.zh_Hans?.toLowerCase().includes(query); + const enLabelMatch = op.label?.en_US?.toLowerCase().includes(query); + const summaryMatch = op.summary?.toLowerCase().includes(query); + const domainMatch = domain.toLowerCase().includes(query); + return ( + idMatch || zhLabelMatch || enLabelMatch || summaryMatch || domainMatch + ); + }); + + if (matchingOps.length > 0) { + result.push({ + domain, + operations: matchingOps, + }); + } + } + + return result; + }, [domainList, groupedOperations, searchQuery]); + + const toggleDomainOpen = (domain: string) => { + setOpenDomains((prev) => ({ + ...prev, + [domain]: prev[domain] !== undefined ? !prev[domain] : false, // default is open (true) + })); + }; + + const isDomainOpen = (domain: string) => { + // If search active, always expand matching domains + if (searchQuery.trim()) return true; + return openDomains[domain] !== false; // defaults to true (open) + }; + + const handleToggleOperation = useCallback( + (opId: string, checked: boolean) => { + if (disabled) return; + if (checked) { + if (!selectedSet.has(opId)) { + onChange([...value, opId]); + } + } else { + onChange(value.filter((id) => id !== opId)); + } + }, + [disabled, onChange, selectedSet, value], + ); + + const handleToggleDomainAll = useCallback( + (domainOps: WorkspaceOperationItem[]) => { + if (disabled) return; + const grantableOps = domainOps.filter((op) => op.grantable); + if (grantableOps.length === 0) return; + + const allGrantableSelected = grantableOps.every((op) => + selectedSet.has(op.id), + ); + + if (allGrantableSelected) { + // Deselect all grantable ops in this domain + const idsToRemove = new Set(grantableOps.map((op) => op.id)); + onChange(value.filter((id) => !idsToRemove.has(id))); + } else { + // Select all grantable ops in this domain + const next = new Set(value); + for (const op of grantableOps) { + next.add(op.id); + } + onChange(Array.from(next)); + } + }, + [disabled, onChange, selectedSet, value], + ); + + const getDomainLabel = (domain: string): string => { + const meta = domainMetaMap.get(domain); + if (meta?.label) { + return ( + extractI18nObject( + meta.label as unknown as Parameters[0], + ) || + meta.label.zh_Hans || + meta.label.en_US || + domain + ); + } + return domain; + }; + + const renderRiskBadge = (risk: WorkspaceOperationItem['risk']) => { + switch (risk) { + case 'dangerous': + return ( + + + {t('workspaceOperations.riskDangerous')} + + ); + case 'write': + return ( + + {t('workspaceOperations.riskWrite')} + + ); + case 'read': + default: + return ( + + {t('workspaceOperations.riskRead')} + + ); + } + }; + + return ( +
+ {/* Search Bar & Summary */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('workspaceOperations.searchPlaceholder')} + className="pl-9" + /> +
+
+ + {t('workspaceOperations.selectedCount', { + count: value.length, + })} + + • + + {t('workspaceOperations.totalCount', { + count: operations.length, + })} + +
+
+ + {/* Empty State */} + {operations.length === 0 && ( +
+ {t('workspaceOperations.noOperations')} +
+ )} + + {operations.length > 0 && filteredDomainGroups.length === 0 && ( +
+ {t('workspaceOperations.noSearchResults')} +
+ )} + + {/* Domain Groups */} + {filteredDomainGroups.length > 0 && ( +
+ {filteredDomainGroups.map(({ domain, operations: domainOps }) => { + const isOpen = isDomainOpen(domain); + const grantableOps = domainOps.filter((op) => op.grantable); + const selectedInDomain = domainOps.filter((op) => + selectedSet.has(op.id), + ); + const allGrantableSelected = + grantableOps.length > 0 && + grantableOps.every((op) => selectedSet.has(op.id)); + const someSelected = + selectedInDomain.length > 0 && !allGrantableSelected; + + return ( + toggleDomainOpen(domain)} + className="rounded-lg border bg-card text-card-foreground shadow-sm" + > + {/* Domain Header */} +
+ + + + + {/* Domain-level Select All */} +
+ +
+
+ + {/* Operations List in Domain */} + +
+ {domainOps.map((op) => { + const isChecked = selectedSet.has(op.id); + const isGrantable = op.grantable; + const hasBothLabels = + op.label?.zh_Hans && + op.label?.en_US && + op.label.zh_Hans !== op.label.en_US; + + const content = ( +
+ + handleToggleOperation(op.id, checked === true) + } + className="mt-1" + /> +
+ +
+ {op.id} +
+ {op.summary && ( +

+ {op.summary} +

+ )} +
+ {!isGrantable && ( + + + + + + + + {t('workspaceOperations.requiresPermission', { + permission: op.permission, + })} + + + )} +
+ ); + + if (!isGrantable) { + return ( + + +
{content}
+
+ + {t('workspaceOperations.requiresPermission', { + permission: op.permission, + })} + +
+ ); + } + + return content; + })} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index 2b7f99ce3..a3d268ac7 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { backendClient } from '@/app/infra/http'; import { Button } from '@/components/ui/button'; @@ -20,6 +20,7 @@ import { Wrench, Sparkles, Puzzle, + Loader2, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -29,10 +30,26 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Plugin } from '@/app/infra/entities/plugin'; -import { MCPServer, Skill } from '@/app/infra/entities/api'; +import { + MCPServer, + Skill, + WorkspaceOperationItem, + DomainMeta, +} from '@/app/infra/entities/api'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; +import WorkspaceOperationsPicker from '@/app/home/components/workspace-operations/WorkspaceOperationsPicker'; function PluginThumbnail({ author, name }: { author: string; name: string }) { const iconURL = useInstalledPluginIcon(author, name); @@ -99,6 +116,24 @@ export default function PipelineExtension({ const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState( [], ); + const [workspaceOperations, setWorkspaceOperations] = useState([]); + const savedWorkspaceOperationsRef = useRef([]); + const [availableWorkspaceOperations, setAvailableWorkspaceOperations] = + useState([]); + const [domains, setDomains] = useState([]); + const [isSavingOperations, setIsSavingOperations] = useState(false); + const [dangerousModalOpen, setDangerousModalOpen] = useState(false); + const pendingOperationsRef = useRef(null); + + const isOperationsDirty = useMemo(() => { + const saved = new Set(savedWorkspaceOperationsRef.current); + if (workspaceOperations.length !== saved.size) return true; + return workspaceOperations.some((op) => !saved.has(op)); + }, [workspaceOperations]); + + const handleResetWorkspaceOperations = () => { + setWorkspaceOperations([...savedWorkspaceOperationsRef.current]); + }; useEffect(() => { loadExtensions(); @@ -147,6 +182,15 @@ export default function PipelineExtension({ setSelectedSkills(selectedSkill); setAllSkills(data.available_skills || []); + + // Load Workspace Operations + const loadedOps = data.workspace_operations || []; + setWorkspaceOperations(loadedOps); + savedWorkspaceOperationsRef.current = loadedOps; + setAvailableWorkspaceOperations( + data.available_workspace_operations || [], + ); + setDomains(data.domains || []); } catch (error) { console.error('Failed to load extensions:', error); toast.error(t('pipelines.extensions.loadError')); @@ -183,6 +227,9 @@ export default function PipelineExtension({ newEnableAllMCPServers ?? enableAllMCPServers, boundSkillIds, newEnableAllSkills ?? enableAllSkills, + undefined, + undefined, + workspaceOperations, ); toast.success(t('pipelines.extensions.saveSuccess')); } catch (error) { @@ -193,6 +240,63 @@ export default function PipelineExtension({ } }; + const executeSaveWorkspaceOperations = async (opsToSave: string[]) => { + setIsSavingOperations(true); + try { + const boundPluginsArray = selectedPlugins.map((plugin) => { + const metadata = plugin.manifest.manifest.metadata; + return { + author: metadata.author || '', + name: metadata.name, + }; + }); + const boundMCPServerIds = selectedMCPServers.map( + (server) => server.uuid || '', + ); + const boundSkillIds = selectedSkills.map((skill) => skill.name); + + await backendClient.updatePipelineExtensions( + pipelineId, + boundPluginsArray, + boundMCPServerIds, + enableAllPlugins, + enableAllMCPServers, + boundSkillIds, + enableAllSkills, + undefined, + undefined, + opsToSave, + ); + savedWorkspaceOperationsRef.current = opsToSave; + setWorkspaceOperations(opsToSave); + toast.success(t('workspaceOperations.saveSuccess')); + } catch (error) { + console.error('Failed to save workspace operations:', error); + const message = + typeof error === 'object' && error && 'msg' in error + ? String((error as { msg?: string }).msg || '') + : ''; + toast.error( + t('workspaceOperations.saveFailed') + (message ? `: ${message}` : ''), + ); + } finally { + setIsSavingOperations(false); + } + }; + + const handleSaveWorkspaceOperations = async (opsToSave: string[]) => { + const hasDangerous = opsToSave.some((opId) => { + const op = availableWorkspaceOperations.find((item) => item.id === opId); + return op?.risk === 'dangerous'; + }); + if (hasDangerous) { + pendingOperationsRef.current = opsToSave; + setDangerousModalOpen(true); + return; + } + await executeSaveWorkspaceOperations(opsToSave); + }; + const handleRemovePlugin = async (pluginId: string) => { const newPlugins = selectedPlugins.filter( (p) => getPluginId(p) !== pluginId, @@ -639,6 +743,89 @@ export default function PipelineExtension({ + {/* Workspace Operations Section */} +
+
+
+

+ {t('workspaceOperations.title')} +

+

+ {t('workspaceOperations.scopeHint')} +

+
+
+ {isOperationsDirty && ( + + )} + +
+
+ + +
+ + {/* Dangerous Operations Confirmation Dialog */} + + + + + {t('workspaceOperations.dangerousConfirmTitle')} + + + {t('workspaceOperations.dangerousConfirmDescription')} + + + + { + setDangerousModalOpen(false); + pendingOperationsRef.current = null; + }} + > + {t('workspaceOperations.dangerousConfirmCancel')} + + { + setDangerousModalOpen(false); + if (pendingOperationsRef.current) { + const ops = pendingOperationsRef.current; + pendingOperationsRef.current = null; + await executeSaveWorkspaceOperations(ops); + } + }} + > + {t('workspaceOperations.dangerousConfirmAction')} + + + + + {/* Plugin Selection Dialog */} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 3223ed7b3..1449bae83 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -267,11 +267,30 @@ export interface ApiRespAgent { agent: Agent; } +export interface WorkspaceOperationItem { + id: string; + domain: string; + label: Record; + summary: string; + risk: 'read' | 'write' | 'dangerous'; + permission: string; + grantable: boolean; +} + +export interface DomainMeta { + domain: string; + label: Record; + summary?: string; + count?: number; +} + export interface GetAgentMetadataResponseData { event_processors?: RunnerDescriptor[]; runner_config?: PipelineConfigTab; platform_tools: AgentPlatformTool[]; host_tools?: PluginTool[] | null; + workspace_operations?: WorkspaceOperationItem[]; + domains?: DomainMeta[]; kinds: Array<{ name: AgentKind; supported_event_patterns: string[]; diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index f12b60927..2769db8c3 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -45,6 +45,8 @@ import { GetPipelineResponseData, GetPipelineMetadataResponseData, GetAgentMetadataResponseData, + WorkspaceOperationItem, + DomainMeta, AsyncTask, ApiRespWebChatMessages, ApiRespKnowledgeBases, @@ -609,6 +611,9 @@ export class BackendClient extends BaseHttpClient { }>; bound_skills: string[]; available_skills: Skill[]; + workspace_operations?: string[]; + available_workspace_operations?: WorkspaceOperationItem[]; + domains?: DomainMeta[]; }> { return this.get(`/api/v1/pipelines/${uuid}/extensions`); } @@ -631,6 +636,7 @@ export class BackendClient extends BaseHttpClient { max_tokens?: number; }>, mcp_resource_agent_read_enabled?: boolean, + workspace_operations?: string[], ): Promise { const payload: Record = { bound_plugins, @@ -646,6 +652,9 @@ export class BackendClient extends BaseHttpClient { if (mcp_resource_agent_read_enabled !== undefined) { payload.mcp_resource_agent_read_enabled = mcp_resource_agent_read_enabled; } + if (workspace_operations !== undefined) { + payload.workspace_operations = workspace_operations; + } return this.put(`/api/v1/pipelines/${uuid}/extensions`, payload); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 61af42014..462ae2257 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -3986,5 +3986,34 @@ const enUS = { period: '.', dismiss: 'Dismiss', }, + workspaceOperations: { + title: 'Workspace Operations', + description: + 'Configure workspace unified API operations that this processor can invoke.', + scopeHint: + 'Allow this processor to invoke LangBot workspace unified API operations under its own identity.', + searchPlaceholder: 'Search operation name, ID, or description...', + selectAll: 'Select All', + deselectAll: 'Deselect All', + selectedCount: '{{count}} selected', + totalCount: '{{count}} total', + noOperations: 'No workspace operations available', + noSearchResults: 'No matching operations found', + riskRead: 'Read', + riskWrite: 'Write', + riskDangerous: 'Dangerous', + requiresPermission: 'Requires permission: {{permission}}', + dangerousConfirmTitle: 'Contains Dangerous Workspace Operations', + dangerousConfirmDescription: + 'This processor faces IM users; anyone who can chat with the Bot may trigger these operations. Are you sure you want to save?', + dangerousConfirmAction: 'Confirm Save', + dangerousConfirmCancel: 'Cancel', + saveWorkspaceOperations: 'Save Workspace Operations', + saveSuccess: 'Workspace operations saved successfully', + saveFailed: 'Failed to save workspace operations', + configuredCount: '{{count}} operations configured', + expandDomain: 'Expand', + collapseDomain: 'Collapse', + }, }; export default enUS; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6004a99eb..127ec76b7 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -3755,5 +3755,32 @@ const zhHans = { period: '。', dismiss: '关闭横幅', }, + workspaceOperations: { + title: '工作区操作', + description: '配置处理器可以调用的工作区统一 API 操作。勾选即授权。', + scopeHint: '允许此处理器以自身身份调用 LangBot 工作区统一 API 操作。', + searchPlaceholder: '搜索操作名称、ID 或描述...', + selectAll: '全选', + deselectAll: '取消全选', + selectedCount: '已选 {{count}} 项', + totalCount: '共 {{count}} 项', + noOperations: '暂无可用工作区操作', + noSearchResults: '未找到匹配的操作', + riskRead: '读', + riskWrite: '写', + riskDangerous: '高危', + requiresPermission: '需要权限:{{permission}}', + dangerousConfirmTitle: '包含高危工作区操作', + dangerousConfirmDescription: + '该处理器面向 IM 用户,任何能和 Bot 对话的人都可能触发这些操作。确定要保存吗?', + dangerousConfirmAction: '确认保存', + dangerousConfirmCancel: '取消', + saveWorkspaceOperations: '保存工作区操作', + saveSuccess: '工作区操作保存成功', + saveFailed: '工作区操作保存失败', + configuredCount: '已配置 {{count}} 个操作', + expandDomain: '展开', + collapseDomain: '折叠', + }, }; export default zhHans; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 17b2aad48..0e3cc512f 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -3750,5 +3750,32 @@ const zhHant = { period: '。', dismiss: '關閉橫幅', }, + workspaceOperations: { + title: '工作區操作', + description: '配置處理器可以調用的工作區統一 API 操作。勾選即授權。', + scopeHint: '允許此處理器以自身身份調用 LangBot 工作區統一 API 操作。', + searchPlaceholder: '搜尋操作名稱、ID 或描述...', + selectAll: '全選', + deselectAll: '取消全選', + selectedCount: '已選 {{count}} 項', + totalCount: '共 {{count}} 項', + noOperations: '暫無可用工作區操作', + noSearchResults: '未找到匹配的操作', + riskRead: '讀', + riskWrite: '寫', + riskDangerous: '高危', + requiresPermission: '需要權限:{{permission}}', + dangerousConfirmTitle: '包含高危工作區操作', + dangerousConfirmDescription: + '該處理器面向 IM 用戶,任何能和 Bot 對話的人都可能觸發這些操作。確定要保存嗎?', + dangerousConfirmAction: '確認保存', + dangerousConfirmCancel: '取消', + saveWorkspaceOperations: '保存工作區操作', + saveSuccess: '工作區操作保存成功', + saveFailed: '工作區操作保存失敗', + configuredCount: '已配置 {{count}} 個操作', + expandDomain: '展開', + collapseDomain: '折疊', + }, }; export default zhHant;