mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
feat(api-tools): processor workspace operations authorization and WebUI
LANG-226 Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
f527c61a41
commit
a0405a0b2c
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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<PluginTool[]>([]);
|
||||
const [hostToolCatalogAvailable, setHostToolCatalogAvailable] =
|
||||
useState(true);
|
||||
const [workspaceOperations, setWorkspaceOperations] = useState<
|
||||
WorkspaceOperationItem[]
|
||||
>([]);
|
||||
const [domains, setDomains] = useState<DomainMeta[]>([]);
|
||||
const [dangerousModalOpen, setDangerousModalOpen] = useState(false);
|
||||
const pendingSaveValuesRef = useRef<FormValues | null>(null);
|
||||
const pendingResolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(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<typeof formSchema>;
|
||||
|
||||
@@ -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<string, any>;
|
||||
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<boolean> => {
|
||||
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<boolean>((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(
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
<section className="space-y-3 border-t pt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('workspaceOperations.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('workspaceOperations.description')}
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowed_workspace_operations"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<WorkspaceOperationsPicker
|
||||
operations={workspaceOperations}
|
||||
domains={domains}
|
||||
value={field.value}
|
||||
onChange={(next) =>
|
||||
form.setValue(
|
||||
'allowed_workspace_operations',
|
||||
next,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -903,6 +988,48 @@ function AgentFormComponent(
|
||||
steps={runnerGuideSteps}
|
||||
testId="runner-setup-guide"
|
||||
/>
|
||||
<AlertDialog
|
||||
open={dangerousModalOpen}
|
||||
onOpenChange={setDangerousModalOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('workspaceOperations.dangerousConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('workspaceOperations.dangerousConfirmDescription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setDangerousModalOpen(false);
|
||||
pendingResolveRef.current?.(false);
|
||||
pendingSaveValuesRef.current = null;
|
||||
pendingResolveRef.current = null;
|
||||
}}
|
||||
>
|
||||
{t('workspaceOperations.dangerousConfirmCancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
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')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||
|
||||
// Map domain names to domain metadata
|
||||
const domainMetaMap = useMemo(() => {
|
||||
const map = new Map<string, DomainMeta>();
|
||||
for (const d of domains) {
|
||||
map.set(d.domain, d);
|
||||
}
|
||||
return map;
|
||||
}, [domains]);
|
||||
|
||||
// Group operations by domain
|
||||
const groupedOperations = useMemo(() => {
|
||||
const groups = new Map<string, WorkspaceOperationItem[]>();
|
||||
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<typeof extractI18nObject>[0],
|
||||
) ||
|
||||
meta.label.zh_Hans ||
|
||||
meta.label.en_US ||
|
||||
domain
|
||||
);
|
||||
}
|
||||
return domain;
|
||||
};
|
||||
|
||||
const renderRiskBadge = (risk: WorkspaceOperationItem['risk']) => {
|
||||
switch (risk) {
|
||||
case 'dangerous':
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="gap-1 px-1.5 py-0 text-[10px] font-semibold"
|
||||
>
|
||||
<ShieldAlert className="size-3" />
|
||||
{t('workspaceOperations.riskDangerous')}
|
||||
</Badge>
|
||||
);
|
||||
case 'write':
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0 text-[10px] font-semibold"
|
||||
>
|
||||
{t('workspaceOperations.riskWrite')}
|
||||
</Badge>
|
||||
);
|
||||
case 'read':
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400 px-1.5 py-0 text-[10px] font-semibold"
|
||||
>
|
||||
{t('workspaceOperations.riskRead')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar & Summary */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('workspaceOperations.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t('workspaceOperations.selectedCount', {
|
||||
count: value.length,
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{t('workspaceOperations.totalCount', {
|
||||
count: operations.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{operations.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
{t('workspaceOperations.noOperations')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operations.length > 0 && filteredDomainGroups.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
{t('workspaceOperations.noSearchResults')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain Groups */}
|
||||
{filteredDomainGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<Collapsible
|
||||
key={domain}
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleDomainOpen(domain)}
|
||||
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
{/* Domain Header */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/40 px-3 py-2.5">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left text-sm font-semibold hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span>{getDomainLabel(domain)}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground font-normal">
|
||||
({domain})
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 text-[11px] font-normal"
|
||||
>
|
||||
{selectedInDomain.length} / {domainOps.length}
|
||||
</Badge>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Domain-level Select All */}
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-xs font-medium cursor-pointer select-none',
|
||||
(disabled || grantableOps.length === 0) &&
|
||||
'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
allGrantableSelected
|
||||
? true
|
||||
: someSelected
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
disabled={disabled || grantableOps.length === 0}
|
||||
onCheckedChange={() => handleToggleDomainAll(domainOps)}
|
||||
aria-label={`${domain} ${t('workspaceOperations.selectAll')}`}
|
||||
/>
|
||||
<span>{t('workspaceOperations.selectAll')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operations List in Domain */}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y">
|
||||
{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 = (
|
||||
<div
|
||||
key={op.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
!isGrantable && 'opacity-60 bg-muted/20',
|
||||
isGrantable && 'hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`op-${op.id}`}
|
||||
checked={isChecked}
|
||||
disabled={disabled || !isGrantable}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleOperation(op.id, checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label
|
||||
htmlFor={`op-${op.id}`}
|
||||
className={cn(
|
||||
'block font-medium text-sm leading-5',
|
||||
isGrantable
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
{hasBothLabels
|
||||
? `${op.label.zh_Hans} (${op.label.en_US})`
|
||||
: extractI18nObject(
|
||||
op.label as unknown as Parameters<
|
||||
typeof extractI18nObject
|
||||
>[0],
|
||||
) ||
|
||||
op.label?.zh_Hans ||
|
||||
op.label?.en_US ||
|
||||
op.id}
|
||||
</span>
|
||||
{renderRiskBadge(op.risk)}
|
||||
</div>
|
||||
</label>
|
||||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||
{op.id}
|
||||
</div>
|
||||
{op.summary && (
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-normal">
|
||||
{op.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isGrantable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 text-amber-600 dark:text-amber-400 mt-1 cursor-help">
|
||||
<HelpCircle className="size-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t('workspaceOperations.requiresPermission', {
|
||||
permission: op.permission,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isGrantable) {
|
||||
return (
|
||||
<Tooltip key={op.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{content}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t('workspaceOperations.requiresPermission', {
|
||||
permission: op.permission,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>(
|
||||
[],
|
||||
);
|
||||
const [workspaceOperations, setWorkspaceOperations] = useState<string[]>([]);
|
||||
const savedWorkspaceOperationsRef = useRef<string[]>([]);
|
||||
const [availableWorkspaceOperations, setAvailableWorkspaceOperations] =
|
||||
useState<WorkspaceOperationItem[]>([]);
|
||||
const [domains, setDomains] = useState<DomainMeta[]>([]);
|
||||
const [isSavingOperations, setIsSavingOperations] = useState(false);
|
||||
const [dangerousModalOpen, setDangerousModalOpen] = useState(false);
|
||||
const pendingOperationsRef = useRef<string[] | null>(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({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workspace Operations Section */}
|
||||
<div className="space-y-3 border-t pt-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('workspaceOperations.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('workspaceOperations.scopeHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOperationsDirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetWorkspaceOperations}
|
||||
disabled={isSavingOperations}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveWorkspaceOperations(workspaceOperations)}
|
||||
disabled={isSavingOperations || !isOperationsDirty}
|
||||
>
|
||||
{isSavingOperations ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{t('workspaceOperations.saveWorkspaceOperations')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkspaceOperationsPicker
|
||||
operations={availableWorkspaceOperations}
|
||||
domains={domains}
|
||||
value={workspaceOperations}
|
||||
onChange={setWorkspaceOperations}
|
||||
disabled={isSavingOperations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dangerous Operations Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={dangerousModalOpen}
|
||||
onOpenChange={setDangerousModalOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('workspaceOperations.dangerousConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('workspaceOperations.dangerousConfirmDescription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setDangerousModalOpen(false);
|
||||
pendingOperationsRef.current = null;
|
||||
}}
|
||||
>
|
||||
{t('workspaceOperations.dangerousConfirmCancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
setDangerousModalOpen(false);
|
||||
if (pendingOperationsRef.current) {
|
||||
const ops = pendingOperationsRef.current;
|
||||
pendingOperationsRef.current = null;
|
||||
await executeSaveWorkspaceOperations(ops);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('workspaceOperations.dangerousConfirmAction')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Plugin Selection Dialog */}
|
||||
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
|
||||
@@ -267,11 +267,30 @@ export interface ApiRespAgent {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationItem {
|
||||
id: string;
|
||||
domain: string;
|
||||
label: Record<string, string>;
|
||||
summary: string;
|
||||
risk: 'read' | 'write' | 'dangerous';
|
||||
permission: string;
|
||||
grantable: boolean;
|
||||
}
|
||||
|
||||
export interface DomainMeta {
|
||||
domain: string;
|
||||
label: Record<string, string>;
|
||||
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[];
|
||||
|
||||
@@ -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<object> {
|
||||
const payload: Record<string, unknown> = {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user