mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
feat(api-tools): default workspace operations and runner manifest declaration (LANG-232)
- Manifest reading: RunnerDescriptor reads permissions.workspace_operations with SDK 0.7.19 compatibility via getattr fallback - RunnerRegistry: strip workspace_operations when running with SDK 0.7.19 to prevent extra_forbidden; add workspace_operations to pipeline options - Default operations: get_default_workspace_operations computes declared ∩ (exposure == 'all') - Frontend: AgentFormComponent and AgentCreateContent default allowed_workspace_operations, strip grantable=false with toast warning - Runtime intersection: resolve_authorized_workspace_operations calculates configured ∩ declared for plugin runners and narrows automatically on upgrade, while LocalAgent is unrestricted - Full unit test coverage for manifest reading, default selection, runtime narrowing, and SDK compatibility Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
244336ec57
commit
111aabd985
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
@@ -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<string, unknown> = {
|
||||
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);
|
||||
|
||||
@@ -591,6 +591,16 @@ function AgentFormComponent(
|
||||
if (formName === 'runner') {
|
||||
form.setValue('runner', values, { shouldDirty: true });
|
||||
updateSnapshotIfInitial(`runner.${stageName}`, wasDirty);
|
||||
const runnerId = (values as Record<string, unknown>)?.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<boolean> => {
|
||||
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<boolean>((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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3781,6 +3781,7 @@ const zhHans = {
|
||||
configuredCount: '已配置 {{count}} 个操作',
|
||||
expandDomain: '展开',
|
||||
collapseDomain: '折叠',
|
||||
ungrantableRemoved: '部分操作因权限不足未自动勾选/已移除',
|
||||
},
|
||||
};
|
||||
export default zhHans;
|
||||
|
||||
@@ -3776,6 +3776,7 @@ const zhHant = {
|
||||
configuredCount: '已配置 {{count}} 個操作',
|
||||
expandDomain: '展開',
|
||||
collapseDomain: '折疊',
|
||||
ungrantableRemoved: '部分操作因權限不足未自動勾選/已移除',
|
||||
},
|
||||
};
|
||||
export default zhHant;
|
||||
|
||||
Reference in New Issue
Block a user