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:
RockChinQ
2026-11-01 12:51:37 +08:00
co-authored by multica-agent
parent 244336ec57
commit 111aabd985
14 changed files with 491 additions and 21 deletions
@@ -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):
+52 -1
View File
@@ -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]
+11 -1
View File
@@ -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',
]
+17 -8
View File
@@ -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) {
+4
View File
@@ -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 {
+2
View File
@@ -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;
+1
View File
@@ -3781,6 +3781,7 @@ const zhHans = {
configuredCount: '已配置 {{count}} 个操作',
expandDomain: '展开',
collapseDomain: '折叠',
ungrantableRemoved: '部分操作因权限不足未自动勾选/已移除',
},
};
export default zhHans;
+1
View File
@@ -3776,6 +3776,7 @@ const zhHant = {
configuredCount: '已配置 {{count}} 個操作',
expandDomain: '展開',
collapseDomain: '折疊',
ungrantableRemoved: '部分操作因權限不足未自動勾選/已移除',
},
};
export default zhHant;