From 67ff4d30543a322668fb2710c20554db569b538a Mon Sep 17 00:00:00 2001 From: TyperBody Date: Mon, 28 Sep 2026 02:06:57 +0800 Subject: [PATCH] fix(operation-trace): record uninstalls and deletes, drop assistant chatter - Split the route rules on the HTTP method so a DELETE is classified as a destructive action instead of falling into the read/write bucket. Uninstalling a plugin or skill and deleting a knowledge base or MCP server were recorded as a view/update and left no readable trace. - Add plugin_uninstall, skill_uninstall, knowledge_base_delete and mcp_delete. - Skip the assistant's own conversation traffic: a chat session produced opaque create/resource rows that answered nothing. - Localize the new actions and the assistant_conversation resource type in all locales. - Sync the assistant tool action list and apply ruff formatting. --- .../pkg/api/http/service/assistant_tools.py | 25 +++- src/langbot/pkg/operation_trace/service.py | 130 +++++++++++++----- web/src/i18n/locales/en-US.ts | 5 + web/src/i18n/locales/es-ES.ts | 5 + web/src/i18n/locales/ja-JP.ts | 5 + web/src/i18n/locales/ru-RU.ts | 5 + web/src/i18n/locales/th-TH.ts | 5 + web/src/i18n/locales/vi-VN.ts | 5 + web/src/i18n/locales/zh-Hans.ts | 5 + web/src/i18n/locales/zh-Hant.ts | 5 + 10 files changed, 157 insertions(+), 38 deletions(-) diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index 4d81f29e0..a850f428e 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -173,17 +173,34 @@ class CreateKnowledgeBase(CreatePipeline): #: further (e.g. the audit surface needs ``audit.view``, which only the owner #: and admin hold) and both are enforced identically when listing and calling. TOOLS = { - 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False, None), + 'list_resources': ( + ListResources, + 'List existing Workspace resources. Discover IDs before using them.', + False, + None, + ), 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None), 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None), 'list_operation_logs': ( ListOperationLogs, - 'Read the Workspace operation trace (who changed or viewed what, with before/after fields). ' - 'Owner and admin only. Use it to answer "who did what" questions about this Workspace.', + 'Search the Workspace operation trace (who changed or viewed what, with before/after fields). ' + 'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" ' + 'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or ' + 'search="install"); use the exact "action" key only when known - valid keys include ' + 'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, ' + 'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, ' + 'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, ' + 'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. ' + 'Combine resource_type and actor to answer "who installed X".', False, Permission.AUDIT_VIEW, ), - 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True, None), + 'create_pipeline': ( + CreatePipeline, + 'Create an unconnected Pipeline draft. Requires user confirmation.', + True, + None, + ), 'configure_pipeline': ( ConfigurePipeline, 'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.', diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 9ceab345a..09318d7c8 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -360,6 +360,15 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='plugin', ), + # Uninstalling is a mutation with its own verb. Without it the DELETE on the + # shared ``/plugins//`` route falls through to the read rule + # and an uninstall is mislabelled as a view. + ActionRule( + action='plugin_uninstall', + category='extension', + bucket='write', + resource_type='plugin', + ), ActionRule( action='plugin_upgrade', category='extension', @@ -384,6 +393,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='skill', ), + ActionRule( + action='skill_uninstall', + category='extension', + bucket='write', + resource_type='skill', + ), # --- Knowledge & MCP ------------------------------------------------- ActionRule( action='knowledge_base_view', @@ -397,6 +412,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='knowledge_base', ), + ActionRule( + action='knowledge_base_delete', + category='knowledge', + bucket='write', + resource_type='knowledge_base', + ), ActionRule( action='mcp_view', category='integration', @@ -409,6 +430,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='mcp_server', ), + ActionRule( + action='mcp_delete', + category='integration', + bucket='write', + resource_type='mcp_server', + ), # --- Pipelines ------------------------------------------------------- ActionRule( action='pipeline_extensions_update', @@ -471,6 +498,16 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='read', resource_type='system', ), + # A management-assistant chat session is not a Workspace resource change: + # creating a conversation and sending a turn produce one opaque + # ``create/resource`` row each. They carry no "what changed" answer, so the + # whole family is dropped rather than cluttering the log with noise. + ActionRule( + action='assistant_session', + category='runtime', + bucket='skip', + resource_type='assistant_conversation', + ), ) ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule for rule in ACTION_RULE_TABLE} @@ -479,45 +516,57 @@ _READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'}) # Route rules evaluated in order; the first match wins, so the most specific # rule must precede its prefix. Each rule is ``(fragments, read_action, -# write_action)``: every fragment must appear in the lowered route (a tuple -# expresses an AND, which lets ``/plugins///config`` be told -# apart from ``/plugins//``), and the action is selected by -# whether the method is a read verb. -_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = ( +# write_action, delete_action)``: every fragment must appear in the lowered +# route (a tuple expresses an AND, which lets ``/plugins///config`` +# be told apart from ``/plugins//``), and the action is selected by +# the method bucket -- read verb, ``DELETE``, or any other verb. +# +# ``delete_action`` is distinct because several resources register their read +# and their destroy on the *same* path (``GET``/``DELETE /plugins//``, +# ``/knowledge/...``, ``/mcp/servers/``). Keying only on fragments made a +# ``DELETE`` fall into the write bucket and record an uninstall as a view or an +# update, so the destructive operation left no readable trace. +_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str, str | None], ...]] = ( # --- Audit surface itself ------------------------------------------- - (('/settings/operation-logs/export',), 'export', 'export'), - (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view'), - (('/settings/operation-level',), 'settings_view', 'settings_update'), - (('/settings/governance',), 'settings_view', 'settings_update'), - (('/settings/limits',), 'settings_view', 'settings_update'), + (('/settings/operation-logs/export',), 'export', 'export', None), + (('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None), + (('/settings/operation-level',), 'settings_view', 'settings_update', None), + (('/settings/governance',), 'settings_view', 'settings_update', None), + (('/settings/limits',), 'settings_view', 'settings_update', None), # --- Extension lifecycle: plugins ----------------------------------- - (('/plugins/install',), 'plugin_view', 'plugin_install'), - (('/plugins/github',), 'plugin_view', 'plugin_view'), - (('/plugins/', '/config'), 'plugin_view', 'plugin_config'), - (('/plugins/', '/page-api'), 'page_view', 'page_view'), - (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'), - (('/plugins/', '/logs'), 'plugin_view', 'plugin_view'), - (('/plugins',), 'plugin_view', 'plugin_view'), + # ``/plugins/install`` is an install; the bare ``/plugins//`` + # is a read when fetched and an uninstall when deleted. + (('/plugins/install',), 'plugin_view', 'plugin_install', None), + (('/plugins/github',), 'plugin_view', 'plugin_view', None), + (('/plugins/', '/config'), 'plugin_view', 'plugin_config', None), + (('/plugins/', '/page-api'), 'page_view', 'page_view', None), + (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None), + (('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None), + (('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall'), # The pipeline extension bindings (plugins / MCP servers / skills) live on # ``/pipelines//extensions``, not on a plugin. Without this rule the # generic fragment below would classify the change as a plugin config edit. # The read keeps the generic ``view`` for backwards-compatible labelling. - (('/extensions',), 'view', 'pipeline_extensions_update'), + (('/extensions',), 'view', 'pipeline_extensions_update', None), # --- Extension lifecycle: skills ------------------------------------ - (('/skills/', '/install'), 'skill_view', 'skill_install'), - (('/skills',), 'skill_view', 'skill_view'), + (('/skills/', '/install'), 'skill_view', 'skill_install', None), + (('/skills',), 'skill_view', 'skill_view', 'skill_uninstall'), # --- Knowledge bases & MCP servers ---------------------------------- - (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update'), - (('/mcp/', '/config'), 'mcp_view', 'mcp_config'), - (('/mcp',), 'mcp_view', 'mcp_config'), + (('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete'), + (('/mcp/', '/config'), 'mcp_view', 'mcp_config', None), + (('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete'), # --- Member management ---------------------------------------------- - (('/members',), 'member_view', 'member_role_update'), - (('/invitations',), 'member_view', 'member_invite'), + (('/members',), 'member_view', 'member_role_update', None), + (('/invitations',), 'member_view', 'member_invite', None), + # --- Agent-assistant session chatter (never traced) ------------------ + # Placed before the generic verbs so a conversation turn is not recorded as + # an opaque ``create`` on a nameless resource. + (('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session'), # --- Generic resource verbs ----------------------------------------- - (('/export',), 'export', 'export'), - (('/debug',), 'debug', 'debug'), - (('/execute',), 'execute', 'execute'), - (('/publish',), 'publish', 'publish'), + (('/export',), 'export', 'export', None), + (('/debug',), 'debug', 'debug', None), + (('/execute',), 'execute', 'execute', None), + (('/publish',), 'publish', 'publish', None), ) #: Refines the resource family for the generic verb rules. The route rules above @@ -583,19 +632,28 @@ def classify(method: str, route: str) -> ActionRule: upper_method = (method or 'GET').upper() lowered_route = (route or '').lower() is_read = upper_method in _READ_METHODS + is_delete = upper_method == 'DELETE' - for fragments, read_action, write_action in _ROUTE_RULES: + for fragments, read_action, write_action, delete_action in _ROUTE_RULES: if all(fragment in lowered_route for fragment in fragments): - return _with_resource_type(ACTION_RULES_BY_ACTION[read_action if is_read else write_action], route) + if is_read: + action = read_action + elif is_delete: + # Fall back to the generic delete verb when a rule has no + # dedicated destroy action (still a mutation, never a view). + action = delete_action or 'delete' + else: + action = write_action + return _with_resource_type(ACTION_RULES_BY_ACTION[action], route) if is_read: return _with_resource_type(ACTION_RULES_BY_ACTION['view'], route) + if is_delete: + return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) if upper_method == 'POST': return _with_resource_type(ACTION_RULES_BY_ACTION['create'], route) if upper_method in {'PUT', 'PATCH'}: return _with_resource_type(ACTION_RULES_BY_ACTION['update'], route) - if upper_method == 'DELETE': - return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) return _with_resource_type(ACTION_RULES_BY_ACTION['probe'], route) @@ -620,9 +678,13 @@ def bucket_allows(bucket: str, effective_level: int) -> bool: and ``audit`` are both *observation* buckets: viewing a resource and viewing the audit surface itself are reads, so a mutation-level Workspace must not fill its log with page views. They are only persisted once the - Workspace opts into the read level. + Workspace opts into the read level. ``skip`` is charter noise a Workspace + never needs traced (an assistant chat session's own turn traffic), so it is + dropped at every level instead of being mislabelled as a resource change. """ + if bucket == 'skip': + return False if effective_level <= OPERATION_LEVEL_NONE: return False if bucket in ('read', 'audit'): diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b36f78c88..c1fcb2a83 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2797,6 +2797,7 @@ const enUS = { member: 'Member', member_invitation: 'Member invitation', operation_log: 'Operation log', + assistant_conversation: 'Assistant conversation', plugin: 'Extension', plugin_page: 'Extension page', skill: 'Skill', @@ -2822,14 +2823,18 @@ const enUS = { plugin_view: 'View extension pages', plugin_config: 'Change extension config', plugin_install: 'Install extension', + plugin_uninstall: 'Uninstall extension', plugin_upgrade: 'Upgrade extension', page_view: 'View extension page', skill_view: 'View skill', skill_install: 'Install skill', + skill_uninstall: 'Uninstall skill', knowledge_base_view: 'View knowledge base', knowledge_base_update: 'Change knowledge base', + knowledge_base_delete: 'Delete knowledge base', mcp_view: 'View MCP server', mcp_config: 'Change MCP server', + mcp_delete: 'Delete MCP server', pipeline_extensions_update: 'Update pipeline extension bindings', export: 'Export data', execute: 'Execute task', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index a5a1b09e6..f88cc19c2 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2852,6 +2852,7 @@ const esES = { member: 'Miembro', member_invitation: 'Invitación de miembro', operation_log: 'Registro de operaciones', + assistant_conversation: 'Conversación del asistente', plugin: 'Extensión', plugin_page: 'Página de extensión', skill: 'Habilidad', @@ -2877,14 +2878,18 @@ const esES = { plugin_view: 'Ver páginas de extensión', plugin_config: 'Cambiar configuración de extensión', plugin_install: 'Instalar extensión', + plugin_uninstall: 'Desinstalar extensión', plugin_upgrade: 'Actualizar extensión', page_view: 'Ver página de extensión', skill_view: 'Ver habilidad', skill_install: 'Instalar habilidad', + skill_uninstall: 'Desinstalar habilidad', knowledge_base_view: 'Ver base de conocimiento', knowledge_base_update: 'Cambiar base de conocimiento', + knowledge_base_delete: 'Eliminar base de conocimiento', mcp_view: 'Ver servidor MCP', mcp_config: 'Cambiar servidor MCP', + mcp_delete: 'Eliminar servidor MCP', pipeline_extensions_update: 'Actualizar vinculaciones de extensiones del pipeline', export: 'Exportar datos', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 8dcf174ef..a3874a17f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2815,6 +2815,7 @@ const jaJP = { member: 'メンバー', member_invitation: 'メンバー招待', operation_log: '操作ログ', + assistant_conversation: 'アシスタント会話', plugin: '拡張機能', plugin_page: '拡張ページ', skill: 'スキル', @@ -2840,14 +2841,18 @@ const jaJP = { plugin_view: '拡張ページを閲覧', plugin_config: '拡張設定を変更', plugin_install: '拡張をインストール', + plugin_uninstall: '拡張をアンインストール', plugin_upgrade: '拡張をアップグレード', page_view: '拡張ページを閲覧', skill_view: 'スキルを閲覧', skill_install: 'スキルをインストール', + skill_uninstall: 'スキルをアンインストール', knowledge_base_view: 'ナレッジベースを閲覧', knowledge_base_update: 'ナレッジベースを変更', + knowledge_base_delete: 'ナレッジベースを削除', mcp_view: 'MCP サーバーを閲覧', mcp_config: 'MCP サーバーを変更', + mcp_delete: 'MCP サーバーを削除', pipeline_extensions_update: 'パイプライン拡張の紐付けを変更', export: 'データをエクスポート', execute: 'タスクを実行', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 069dd2f6a..f4f614621 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2822,6 +2822,7 @@ const ruRU = { member: 'Участник', member_invitation: 'Приглашение участника', operation_log: 'Журнал операций', + assistant_conversation: 'Сессия ассистента', plugin: 'Расширение', plugin_page: 'Страница расширения', skill: 'Навык', @@ -2847,14 +2848,18 @@ const ruRU = { plugin_view: 'Просмотр страниц расширения', plugin_config: 'Изменение настроек расширения', plugin_install: 'Установка расширения', + plugin_uninstall: 'Удаление расширения', plugin_upgrade: 'Обновление расширения', page_view: 'Просмотр страницы расширения', skill_view: 'Просмотр навыка', skill_install: 'Установка навыка', + skill_uninstall: 'Удаление навыка', knowledge_base_view: 'Просмотр базы знаний', knowledge_base_update: 'Изменение базы знаний', + knowledge_base_delete: 'Удаление базы знаний', mcp_view: 'Просмотр сервера MCP', mcp_config: 'Изменение сервера MCP', + mcp_delete: 'Удаление сервера MCP', pipeline_extensions_update: 'Изменение привязок расширений конвейера', export: 'Экспорт данных', execute: 'Выполнение задачи', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 149c7d822..779c65a0c 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2749,6 +2749,7 @@ const thTH = { member: 'สมาชิก', member_invitation: 'คำเชิญสมาชิก', operation_log: 'บันทึกการดำเนินการ', + assistant_conversation: 'บทสนทนาผู้ช่วย', plugin: 'ส่วนขยาย', plugin_page: 'หน้าส่วนขยาย', skill: 'ทักษะ', @@ -2774,14 +2775,18 @@ const thTH = { plugin_view: 'ดูหน้าส่วนขยาย', plugin_config: 'แก้ไขการตั้งค่าส่วนขยาย', plugin_install: 'ติดตั้งส่วนขยาย', + plugin_uninstall: 'ถอนการติดตั้งส่วนขยาย', plugin_upgrade: 'อัปเกรดส่วนขยาย', page_view: 'ดูหน้าส่วนขยาย', skill_view: 'ดูทักษะ', skill_install: 'ติดตั้งทักษะ', + skill_uninstall: 'ถอนการติดตั้งทักษะ', knowledge_base_view: 'ดูฐานความรู้', knowledge_base_update: 'แก้ไขฐานความรู้', + knowledge_base_delete: 'ลบฐานความรู้', mcp_view: 'ดูเซิร์ฟเวอร์ MCP', mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP', + mcp_delete: 'ลบเซิร์ฟเวอร์ MCP', pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์', export: 'ส่งออกข้อมูล', execute: 'รันงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 84dd9d7f8..a3615658d 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2784,6 +2784,7 @@ const viVN = { member: 'Thành viên', member_invitation: 'Lời mời thành viên', operation_log: 'Nhật ký thao tác', + assistant_conversation: 'Phiên trợ lý', plugin: 'Tiện ích mở rộng', plugin_page: 'Trang tiện ích', skill: 'Kỹ năng', @@ -2809,14 +2810,18 @@ const viVN = { plugin_view: 'Xem trang tiện ích', plugin_config: 'Thay đổi cấu hình tiện ích', plugin_install: 'Cài đặt tiện ích', + plugin_uninstall: 'Gỡ cài đặt tiện ích', plugin_upgrade: 'Nâng cấp tiện ích', page_view: 'Xem trang tiện ích', skill_view: 'Xem kỹ năng', skill_install: 'Cài đặt kỹ năng', + skill_uninstall: 'Gỡ cài đặt kỹ năng', knowledge_base_view: 'Xem cơ sở tri thức', knowledge_base_update: 'Thay đổi cơ sở tri thức', + knowledge_base_delete: 'Xóa cơ sở tri thức', mcp_view: 'Xem máy chủ MCP', mcp_config: 'Thay đổi máy chủ MCP', + mcp_delete: 'Xóa máy chủ MCP', pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline', export: 'Xuất dữ liệu', execute: 'Chạy tác vụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6b8929318..883c97bac 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2647,6 +2647,7 @@ const zhHans = { member: '成员', member_invitation: '成员邀请', operation_log: '操作日志', + assistant_conversation: '助手会话', plugin: '扩展', plugin_page: '扩展页面', skill: '技能', @@ -2672,14 +2673,18 @@ const zhHans = { plugin_view: '查看扩展页面', plugin_config: '修改扩展配置', plugin_install: '安装扩展', + plugin_uninstall: '卸载扩展', plugin_upgrade: '升级扩展', page_view: '查看扩展页面', skill_view: '查看技能', skill_install: '安装技能', + skill_uninstall: '卸载技能', knowledge_base_view: '查看知识库', knowledge_base_update: '修改知识库', + knowledge_base_delete: '删除知识库', mcp_view: '查看 MCP 服务器', mcp_config: '修改 MCP 服务器', + mcp_delete: '删除 MCP 服务器', pipeline_extensions_update: '修改管道扩展绑定', export: '导出数据', execute: '执行任务', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 135627fa0..3c0bd34b9 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2648,6 +2648,7 @@ const zhHant = { member: '成員', member_invitation: '成員邀請', operation_log: '操作日誌', + assistant_conversation: '助手會話', plugin: '擴充', plugin_page: '擴充頁面', skill: '技能', @@ -2673,14 +2674,18 @@ const zhHant = { plugin_view: '查看擴充頁面', plugin_config: '修改擴充設定', plugin_install: '安裝擴充', + plugin_uninstall: '解除安裝擴充', plugin_upgrade: '升級擴充', page_view: '查看擴充頁面', skill_view: '查看技能', skill_install: '安裝技能', + skill_uninstall: '解除安裝技能', knowledge_base_view: '查看知識庫', knowledge_base_update: '修改知識庫', + knowledge_base_delete: '刪除知識庫', mcp_view: '查看 MCP 伺服器', mcp_config: '修改 MCP 伺服器', + mcp_delete: '刪除 MCP 伺服器', pipeline_extensions_update: '修改管道擴展綁定', export: '匯出資料', execute: '執行任務',