diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 45c92e2f1..b2493f90f 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -105,7 +105,22 @@ class StorageResource(typing.TypedDict): workspace_storage: bool -class AgentResources(typing.TypedDict): +class WorkspaceOperationResource(typing.TypedDict, total=False): + """Workspace operation resource payload.""" + + id: str + domain: str | None + label: dict[str, str] + summary: str | None + description: str | None + risk: str | None + side_effect: bool | None + permission: str | None + input_schema: dict[str, typing.Any] + output_schema: dict[str, typing.Any] + + +class AgentResources(typing.TypedDict, total=False): """Agent resources payload.""" models: list[ModelResource] @@ -114,7 +129,7 @@ class AgentResources(typing.TypedDict): skills: list[SkillResource] storage: StorageResource platform_capabilities: dict[str, typing.Any] - workspace_operations: typing.NotRequired[list[dict[str, typing.Any]]] + workspace_operations: typing.NotRequired[list[WorkspaceOperationResource | dict[str, typing.Any]]] class AgentRuntimeContext(typing.TypedDict): diff --git a/src/langbot/pkg/agent/runner/descriptor.py b/src/langbot/pkg/agent/runner/descriptor.py index aab6805a1..64c155f33 100644 --- a/src/langbot/pkg/agent/runner/descriptor.py +++ b/src/langbot/pkg/agent/runner/descriptor.py @@ -48,7 +48,7 @@ class RunnerDescriptor(pydantic.BaseModel): capabilities: RunnerCapabilities = pydantic.Field(default_factory=RunnerCapabilities) """Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc.""" - permissions: RunnerPermissions = pydantic.Field(default_factory=RunnerPermissions) + permissions: RunnerPermissions | dict[str, typing.Any] = pydantic.Field(default_factory=RunnerPermissions) """Requested LangBot resource permissions.""" raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict) @@ -81,3 +81,54 @@ class RunnerDescriptor(pydantic.BaseModel): def supports_steering(self) -> bool: """Check if runner supports run steering/follow-up input.""" return bool(getattr(self.capabilities, 'steering', False)) + + def declared_workspace_operations(self) -> list[str]: + """Return list of workspace operations declared in runner manifest permissions. + + Uses getattr for compatibility with SDK 0.7.19 where workspace_operations does not exist. + """ + permissions = getattr(self, 'permissions', None) + if isinstance(permissions, dict): + return list(permissions.get('workspace_operations') or []) + ops = getattr(permissions, 'workspace_operations', None) + if ops is not None: + return list(ops) + raw_perms = getattr(self, 'raw_manifest', {}).get('permissions', {}) + if isinstance(raw_perms, dict) and 'workspace_operations' in raw_perms: + return list(raw_perms.get('workspace_operations') or []) + return [] + + @property + def workspace_operations(self) -> list[str]: + """Declared workspace operations from manifest.""" + return self.declared_workspace_operations() + + +def is_local_agent(descriptor: RunnerDescriptor | None) -> bool: + """Check if descriptor represents LocalAgent.""" + if descriptor is None: + return True + if descriptor.id == 'defaultrunner' or not descriptor.id.startswith('plugin:'): + return True + return ( + getattr(descriptor, 'plugin_author', None) == 'langbot-team' + and getattr(descriptor, 'plugin_name', None) == 'LocalAgent' + ) or getattr(descriptor, 'id', '') == 'plugin:langbot-team/LocalAgent/default' + + +def resolve_authorized_workspace_operations( + descriptor: RunnerDescriptor | None, + configured_operations: typing.Iterable[str] | None, +) -> list[str]: + """Calculate effective authorized workspace operations at runtime. + + LocalAgent (defaultrunner or non-plugin runner) is not constrained by manifest; + it receives all operations configured by the user. + Plugin runners are restricted to the intersection of configured operations and + operations declared in the runner's manifest permissions. + """ + configured = [str(op) for op in (configured_operations or [])] + if is_local_agent(descriptor): + return configured + declared = set(descriptor.declared_workspace_operations()) + return [op for op in configured if op in declared] diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py index 9298613db..e23503873 100644 --- a/src/langbot/pkg/agent/runner/registry.py +++ b/src/langbot/pkg/agent/runner/registry.py @@ -7,6 +7,7 @@ import asyncio from langbot_plugin.api.entities.builtin.runner.manifest import ( RunnerManifest, + RunnerPermissions, ) from ...core import app @@ -116,7 +117,15 @@ class RunnerRegistry: runner_name=runner_name, ) - typed_manifest = RunnerManifest.model_validate(manifest) + manifest_payload = manifest + if not hasattr(RunnerPermissions, 'workspace_operations'): + if isinstance(manifest, dict) and 'permissions' in manifest and isinstance(manifest['permissions'], dict): + manifest_payload = dict(manifest) + perms = dict(manifest['permissions']) + perms.pop('workspace_operations', None) + manifest_payload['permissions'] = perms + + typed_manifest = RunnerManifest.model_validate(manifest_payload) config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema] descriptor = RunnerDescriptor( @@ -297,6 +306,7 @@ class RunnerRegistry: 'name': descriptor.id, 'label': descriptor.label, 'description': descriptor.description, + 'workspace_operations': descriptor.declared_workspace_operations(), } ) diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index c5bfec3ad..144d2eea6 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -6,7 +6,7 @@ import typing from ...core import app from ...api.http.context import ExecutionContext -from .descriptor import RunnerDescriptor +from .descriptor import RunnerDescriptor, resolve_authorized_workspace_operations from .context_builder import ( AgentResources, ModelResource, @@ -168,7 +168,10 @@ class AgentResourceBuilder: if not (is_local_agent or manifest_declares): return None - allowed_ops = binding.resource_policy.allowed_workspace_operations + allowed_ops = resolve_authorized_workspace_operations( + descriptor, + binding.resource_policy.allowed_workspace_operations, + ) if not allowed_ops: return [] diff --git a/src/langbot/pkg/agent/runner/resource_policy.py b/src/langbot/pkg/agent/runner/resource_policy.py index 1d774781a..8f7887f84 100644 --- a/src/langbot/pkg/agent/runner/resource_policy.py +++ b/src/langbot/pkg/agent/runner/resource_policy.py @@ -67,6 +67,13 @@ class ResourcePolicyProjector: else: allowed_kb_uuids = None + if allowed_workspace_operations is not None: + normalized_workspace_operations = cls.normalize_names(allowed_workspace_operations) + elif 'allowed_workspace_operations' in config: + normalized_workspace_operations = cls.normalize_names(config.get('allowed_workspace_operations')) + else: + normalized_workspace_operations = [] + return ResourcePolicy( allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids), allowed_tool_names=allowed_tool_names, @@ -75,7 +82,7 @@ class ResourcePolicyProjector: allow_all_tools=allow_all_tools, allowed_kb_uuids=allowed_kb_uuids, allowed_skill_names=cls.normalize_optional_names(resolved_skill_names), - allowed_workspace_operations=cls.normalize_names(allowed_workspace_operations), + allowed_workspace_operations=normalized_workspace_operations, ) @staticmethod diff --git a/src/langbot/pkg/api/application/tool_grants.py b/src/langbot/pkg/api/application/tool_grants.py index 0fb7c2fed..93967bc97 100644 --- a/src/langbot/pkg/api/application/tool_grants.py +++ b/src/langbot/pkg/api/application/tool_grants.py @@ -197,7 +197,39 @@ def get_workspace_operations_catalog(ap: Any, context: Any) -> dict[str, Any]: } +def get_default_workspace_operations(ap: Any, declared_operations: Iterable[str] | None) -> list[str]: + """Calculate default workspace operations from runner declared operations. + + Default scope is declared_workspace_operations intersected with catalog operations + where exposure == 'all', non-streaming, and processor principal is allowed. + """ + if not declared_operations: + return [] + + 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', {}) + valid_defaults: list[str] = [] + + for op_id in declared_operations: + spec = operations_map.get(op_id) + if spec is None or spec.streaming: + continue + if spec.principals is not None and PrincipalType.PROCESSOR not in spec.principals: + continue + doc = get_operation_doc(spec.operation_id, side_effect=spec.side_effect) + if doc.exposure == 'all': + valid_defaults.append(op_id) + + return sorted(set(valid_defaults)) + + __all__ = [ + 'get_default_workspace_operations', 'get_workspace_operations_catalog', 'validate_grant_change', ] diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 1ce3d4a1f..1abebd3c7 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -80,14 +80,17 @@ class AgentService: event_processors = [] registry = getattr(self.ap, 'runner_registry', None) if registry is not None: - event_processors = [ - item.model_dump(mode='json') - for item in await registry.list_runners( - context, - usage='event', - use_cache=False, - ) - ] + for item in await registry.list_runners( + context, + usage='event', + use_cache=False, + ): + dumped = item.model_dump(mode='json') + if 'permissions' not in dumped or not isinstance(dumped['permissions'], dict): + dumped['permissions'] = {} + if 'workspace_operations' not in dumped['permissions']: + dumped['permissions']['workspace_operations'] = item.declared_workspace_operations() + event_processors.append(dumped) ws_catalog = get_workspace_operations_catalog(self.ap, context) return { 'event_processors': event_processors, @@ -598,6 +601,12 @@ class AgentService: if not draft and field.get('required') and parameters.get(field['name']) in (None, ''): raise ValueError(f'Required processor parameter: {field["name"]}') config['runner_config'] = {component_ref: parameters} + if existing is None and 'allowed_workspace_operations' not in config: + from ...application.tool_grants import get_default_workspace_operations + + config['allowed_workspace_operations'] = get_default_workspace_operations( + self.ap, descriptor.declared_workspace_operations() + ) # Component-only creation keeps defaults for the detail form, but does # not subscribe to events until configuration is saved and validated. return config, component_ref, [] if draft else descriptor.supported_event_patterns diff --git a/tests/unit_tests/api/test_manifest_declared_workspace_operations.py b/tests/unit_tests/api/test_manifest_declared_workspace_operations.py new file mode 100644 index 000000000..27f8c9cfa --- /dev/null +++ b/tests/unit_tests/api/test_manifest_declared_workspace_operations.py @@ -0,0 +1,265 @@ +"""Unit tests for plugin runner manifest declared workspace operations, default grants, and SDK compatibility.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pydantic +import pytest + +from langbot.pkg.agent.runner.descriptor import ( + RunnerDescriptor, + is_local_agent, + resolve_authorized_workspace_operations, +) +from langbot.pkg.agent.runner.registry import RunnerRegistry +from langbot.pkg.api.application.tool_grants import get_default_workspace_operations +from langbot.pkg.api.http.service.agent import AgentService + + +def test_declared_workspace_operations_reading(): + """Test reading workspace operations from descriptor permissions.""" + # With declared operations + desc = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/my-plugin/runner', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'my-plugin', + 'runner_name': 'runner', + 'label': {'en_US': 'Test'}, + 'usages': ['agent'], + 'permissions': { + 'workspace_operations': ['bot.list', 'stats.basic'], + }, + } + ) + assert desc.declared_workspace_operations() == ['bot.list', 'stats.basic'] + assert desc.workspace_operations == ['bot.list', 'stats.basic'] + + # Without permissions or workspace_operations + desc_empty = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/my-plugin/runner2', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'my-plugin', + 'runner_name': 'runner2', + 'label': {'en_US': 'Test'}, + 'usages': ['agent'], + 'permissions': {}, + } + ) + assert desc_empty.declared_workspace_operations() == [] + + +def test_declared_workspace_operations_sdk_0719_compat(): + """Test compatibility with older SDK where RunnerPermissions lacks workspace_operations.""" + + class LegacyPermissions(pydantic.BaseModel): + models: list[str] = [] + tools: list[str] = [] + model_config = pydantic.ConfigDict(extra='forbid') + + desc = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/my-plugin/runner', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'my-plugin', + 'runner_name': 'runner', + 'label': {'en_US': 'Test'}, + 'usages': ['agent'], + } + ) + desc.permissions = LegacyPermissions(models=['invoke']) + # Must not raise and must return empty list + assert desc.declared_workspace_operations() == [] + + +def test_resolve_authorized_workspace_operations_local_agent(): + """LocalAgent is not constrained by manifest; receives all configured operations.""" + # Builtin LocalAgent via ID + desc_builtin = RunnerDescriptor.model_validate( + { + 'id': 'plugin:langbot-team/LocalAgent/default', + 'source': 'plugin', + 'plugin_author': 'langbot-team', + 'plugin_name': 'LocalAgent', + 'runner_name': 'default', + 'label': {'en_US': 'LocalAgent'}, + 'usages': ['agent'], + 'permissions': {}, # No manifest declarations + } + ) + assert is_local_agent(desc_builtin) + configured = ['bot.list', 'bot.create', 'stats.basic'] + effective = resolve_authorized_workspace_operations(desc_builtin, configured) + assert effective == configured + + # None descriptor or defaultrunner + assert resolve_authorized_workspace_operations(None, configured) == configured + + +def test_resolve_authorized_workspace_operations_plugin_intersection_and_narrowing(): + """Plugin runner is restricted to intersection of configured and declared operations, narrowing on upgrade.""" + desc_v1 = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/plugin/runner', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'plugin', + 'runner_name': 'runner', + 'label': {'en_US': 'Plugin'}, + 'usages': ['agent'], + 'permissions': { + 'workspace_operations': ['bot.list', 'stats.basic', 'audit.log.list'], + }, + } + ) + # Configured operations by user + user_configured = ['bot.list', 'stats.basic', 'bot.delete'] + + # Effective is configured ∩ declared: 'bot.list' and 'stats.basic' (bot.delete was not declared) + effective_v1 = resolve_authorized_workspace_operations(desc_v1, user_configured) + assert effective_v1 == ['bot.list', 'stats.basic'] + + # Plugin v2 upgrades and drops 'stats.basic' from manifest + desc_v2 = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/plugin/runner', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'plugin', + 'runner_name': 'runner', + 'label': {'en_US': 'Plugin'}, + 'usages': ['agent'], + 'permissions': { + 'workspace_operations': ['bot.list', 'audit.log.list'], + }, + } + ) + # Even though user configuration still has 'stats.basic', runtime narrows automatically + effective_v2 = resolve_authorized_workspace_operations(desc_v2, user_configured) + assert effective_v2 == ['bot.list'] + + +def test_registry_sdk_0719_manifest_loading_compat(monkeypatch): + """Test registry strips workspace_operations when running with SDK 0.7.19 without workspace_operations.""" + app_mock = MagicMock() + registry = RunnerRegistry(app_mock) + + # Simulate SDK 0.7.19 where RunnerPermissions does not have workspace_operations + import langbot.pkg.agent.runner.registry as reg_module + + class MockOldRunnerPermissions(pydantic.BaseModel): + models: list[str] = [] + tools: list[str] = [] + model_config = pydantic.ConfigDict(extra='forbid') + + monkeypatch.setattr(reg_module, 'RunnerPermissions', MockOldRunnerPermissions) + + raw_runner_data = { + 'plugin_author': 'test-author', + 'plugin_name': 'test-plugin', + 'runner_name': 'default', + 'manifest': { + 'id': 'default', + 'name': 'default', + 'component_kind': 'Runner', + 'usages': ['agent'], + 'label': {'en_US': 'Test'}, + 'permissions': { + 'workspace_operations': ['bot.list', 'stats.basic'], + }, + 'config_schema': [], + }, + } + + # Should not raise pydantic.ValidationError (extra forbidden) + desc = registry._validate_and_build_descriptor(raw_runner_data) + assert desc is not None + assert desc.id == 'plugin:test-author/test-plugin/default' + + +@pytest.mark.asyncio +async def test_registry_pipeline_metadata_includes_workspace_operations(): + """get_runner_metadata_for_pipeline includes workspace_operations in runner options.""" + app_mock = MagicMock() + registry = RunnerRegistry(app_mock) + + desc = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/plugin/runner', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'plugin', + 'runner_name': 'runner', + 'label': {'en_US': 'Runner Label'}, + 'usages': ['agent'], + 'permissions': { + 'workspace_operations': ['bot.list', 'stats.basic'], + }, + 'config_schema': [], + } + ) + registry.list_runners = AsyncMock(return_value=[desc]) + + options, stages = await registry.get_runner_metadata_for_pipeline(MagicMock()) + assert len(options) == 1 + assert options[0]['name'] == desc.id + assert options[0]['workspace_operations'] == ['bot.list', 'stats.basic'] + + +def test_get_default_workspace_operations(): + """get_default_workspace_operations filters declared operations to exposure == 'all' and non-streaming.""" + from types import SimpleNamespace + + app = SimpleNamespace(application_api=None) + + # Empty declared operations + assert get_default_workspace_operations(app, []) == [] + assert get_default_workspace_operations(app, None) == [] + + # Valid operations with real ApplicationAPI + defaults = get_default_workspace_operations(app, ['bot.list', 'stats.basic', 'invalid.operation']) + assert 'bot.list' in defaults + assert 'stats.basic' in defaults + assert 'invalid.operation' not in defaults + + +@pytest.mark.asyncio +async def test_prepare_event_processor_defaults_workspace_operations(): + """_prepare_event_processor defaults allowed_workspace_operations on creation.""" + app_mock = MagicMock() + app_mock.application_api = None + app_mock.llm_model_service.apply_default_model = AsyncMock(side_effect=lambda ctx, schema, params: params) + app_mock.pipeline_service._get_default_values_from_schema = MagicMock(return_value={}) + service = AgentService(app_mock) + + desc = RunnerDescriptor.model_validate( + { + 'id': 'plugin:author/plugin/processor', + 'source': 'plugin', + 'plugin_author': 'author', + 'plugin_name': 'plugin', + 'runner_name': 'processor', + 'label': {'en_US': 'Processor'}, + 'usages': ['event'], + 'supported_event_patterns': ['message.*'], + 'permissions': { + 'workspace_operations': ['bot.list', 'stats.basic'], + }, + 'config_schema': [], + } + ) + app_mock.runner_registry.get = AsyncMock(return_value=desc) + + # Creating draft event processor without config + data = {'component_ref': 'plugin:author/plugin/processor'} + config, ref, patterns = await service._prepare_event_processor(MagicMock(), data, existing=None) + + assert ref == 'plugin:author/plugin/processor' + assert 'allowed_workspace_operations' in config + assert 'bot.list' in config['allowed_workspace_operations'] + assert 'stats.basic' in config['allowed_workspace_operations'] diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index c7d96248f..f72bca5c9 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -130,9 +130,46 @@ export default function AgentCreateContent({ (stage) => stage.name === runner, ); const parameters = runnerStage ? getDefaultValues(runnerStage.config) : {}; - const config = { + + let defaultWorkspaceOps: string[] | undefined = undefined; + if (runner.startsWith('plugin:')) { + let declared: string[] = []; + if (kind === 'event_processor') { + const processorDescriptor = metadata?.event_processors?.find( + (p) => p.id === runner, + ); + declared = processorDescriptor?.permissions?.workspace_operations ?? []; + } else { + const option = runnerOptions.find((opt) => opt.name === runner); + declared = (option as any)?.workspace_operations ?? []; + } + const catalogOps = metadata?.workspace_operations ?? []; + const validOpIds = new Set(catalogOps.map((op) => op.id)); + const filteredDefaults = declared.filter((opId) => validOpIds.has(opId)); + + const ungrantable = filteredDefaults.filter((opId) => { + const op = catalogOps.find((candidate) => candidate.id === opId); + return op && op.grantable === false; + }); + if (ungrantable.length > 0) { + toast.warning( + t('workspaceOperations.ungrantableRemoved', { + count: ungrantable.length, + }), + ); + } + defaultWorkspaceOps = filteredDefaults.filter((opId) => { + const op = catalogOps.find((candidate) => candidate.id === opId); + return !op || op.grantable !== false; + }); + } + + const config: Record = { runner: { id: runner }, runner_config: { [runner]: parameters }, + ...(defaultWorkspaceOps !== undefined + ? { allowed_workspace_operations: defaultWorkspaceOps } + : {}), }; try { if ( @@ -197,7 +234,11 @@ export default function AgentCreateContent({ description: '', emoji: values.emoji, component_ref: runner, - ...(kind === 'event_processor' ? {} : { config }), + ...(kind === 'event_processor' + ? defaultWorkspaceOps !== undefined + ? { config: { allowed_workspace_operations: defaultWorkspaceOps } } + : {} + : { config }), }); toast.success(t('agents.createSuccess')); onCreated(response.uuid); diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index b2a6e0272..7b5d97b9a 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -591,6 +591,16 @@ function AgentFormComponent( if (formName === 'runner') { form.setValue('runner', values, { shouldDirty: true }); updateSnapshotIfInitial(`runner.${stageName}`, wasDirty); + const runnerId = (values as Record)?.id; + if (typeof runnerId === 'string' && runnerId.startsWith('plugin:')) { + const option = runnerOptions.find((opt) => opt.name === runnerId); + if (option && Array.isArray((option as any).workspace_operations)) { + const declared = (option as any).workspace_operations as string[]; + const validOpIds = new Set(workspaceOperations.map((op) => op.id)); + const defaultOps = declared.filter((opId) => validOpIds.has(opId)); + form.setValue('allowed_workspace_operations', defaultOps, { shouldDirty: true }); + } + } return; } @@ -723,20 +733,39 @@ function AgentFormComponent( const confirmAndSave = useCallback( (values: FormValues): Promise => { - const hasDangerous = values.allowed_workspace_operations.some((opId) => { + const ungrantableOps = values.allowed_workspace_operations.filter((opId) => { + const op = workspaceOperations.find((item) => item.id === opId); + return op && op.grantable === false; + }); + + let effectiveValues = values; + if (ungrantableOps.length > 0) { + const filteredOps = values.allowed_workspace_operations.filter((opId) => { + const op = workspaceOperations.find((item) => item.id === opId); + return !op || op.grantable !== false; + }); + toast.warning(t('workspaceOperations.ungrantableRemoved', { count: ungrantableOps.length })); + effectiveValues = { + ...values, + allowed_workspace_operations: filteredOps, + }; + form.setValue('allowed_workspace_operations', filteredOps, { shouldDirty: true }); + } + + const hasDangerous = effectiveValues.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; + pendingSaveValuesRef.current = effectiveValues; pendingResolveRef.current = resolve; setDangerousModalOpen(true); }); } - return saveValues(values); + return saveValues(effectiveValues); }, - [saveValues, workspaceOperations], + [form, saveValues, t, workspaceOperations], ); function handleSubmit(values: FormValues) { diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 1449bae83..a3590c890 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -192,6 +192,10 @@ export interface RunnerDescriptor { plugin_name: string; config_schema: import('../form/dynamic').IDynamicFormItemSchema[]; supported_event_patterns: string[]; + permissions?: { + workspace_operations?: string[]; + [key: string]: unknown; + }; } export interface ProcessorRun { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 462ae2257..aad759ba4 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -4014,6 +4014,8 @@ const enUS = { configuredCount: '{{count}} operations configured', expandDomain: 'Expand', collapseDomain: 'Collapse', + ungrantableRemoved: + 'Some operations were unselected or removed due to insufficient permissions.', }, }; export default enUS; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 127ec76b7..f62506131 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -3781,6 +3781,7 @@ const zhHans = { configuredCount: '已配置 {{count}} 个操作', expandDomain: '展开', collapseDomain: '折叠', + ungrantableRemoved: '部分操作因权限不足未自动勾选/已移除', }, }; export default zhHans; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 0e3cc512f..90a54b73a 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -3776,6 +3776,7 @@ const zhHant = { configuredCount: '已配置 {{count}} 個操作', expandDomain: '展開', collapseDomain: '折疊', + ungrantableRemoved: '部分操作因權限不足未自動勾選/已移除', }, }; export default zhHant;