From 291b2ebeb65ca418caa4dbce5f54901f66701290 Mon Sep 17 00:00:00 2001 From: probe Date: Mon, 26 Oct 2026 22:24:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(buddy):=20=E6=8F=92=E4=BB=B6=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E8=B4=A1=E7=8C=AE=20MCP=20=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=99=A8=E4=B8=8E=E5=AD=90=E4=BB=A3=E7=90=86=20(#468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:一项完整能力常需要技能、MCP 连接器和子代理配套,但插件清单目前只能贡献技能,连接器和子代理要用户手动到设置里添加,卸载时也不会清理。 做了什么: - 清单新增 contributes.mcpConnectors(仅 streamable-http、无凭据的 HTTPS 地址,默认关闭)与 contributes.subagents(仅 NPC,工具只能引用本包声明的连接器,文件权限默认只读),需要 API 4。 - 主进程汇总已安装插件的声明,服务进程对账:创建、更新、移除对应记录并随插件启停,不新增数据库迁移;用台账文件区分用户改动,读不出台账时不删除任何东西。 - 安装审核页列出连接器与子代理;卸载对话框提前列出用户改过、将被保留的内容。 - plugin-creator 技能补充说明与类型声明。 Co-Authored-By: Claude Sonnet 5.5 --- .../electron/main/app/DesktopIntegrations.ts | 6 + .../electron/main/app/DesktopRuntimeHost.ts | 8 + .../main/extensions/registerExtensionIpc.ts | 11 +- apps/buddy/electron/preload/extensions.ts | 1 + .../platform/extensions/ExtensionService.ts | 26 +- .../__tests__/ExtensionService.spec.ts | 29 ++ .../resources/skills/plugin-creator/SKILL.md | 2 + .../skills/plugin-creator/references/api.d.ts | 31 ++ .../references/mcp-and-subagents.md | 48 +++ apps/buddy/service/src/BuddyService.ts | 24 ++ .../service/src/buddies/BuddiesService.ts | 5 + .../src/plugins/ExtensionCapabilitySync.ts | 287 ++++++++++++++++++ .../__tests__/ExtensionCapabilitySync.spec.ts | 226 ++++++++++++++ .../__tests__/capabilityLedgerFile.spec.ts | 27 ++ .../src/plugins/capabilityLedgerFile.ts | 44 +++ .../service/src/storage/buddyRepository.ts | 27 ++ .../__tests__/extensionCapabilities.spec.ts | 57 ++++ apps/buddy/shared/extensions/extensionApi.ts | 3 + .../extensions/extensionCapabilities.ts | 82 +++++ .../shared/extensions/extensionManifest.ts | 13 + .../src/modules/extensions/extensionLabels.ts | 24 ++ .../widgets/DesktopExtensionInstallReview.vue | 42 +++ .../widgets/DesktopExtensionManager.vue | 11 +- .../DesktopExtensionUninstallDialog.vue | 13 +- .../DesktopExtensionInstallReview.spec.ts | 32 ++ .../DesktopExtensionUninstallDialog.spec.ts | 36 +++ 26 files changed, 1110 insertions(+), 5 deletions(-) create mode 100644 apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md create mode 100644 apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts create mode 100644 apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts create mode 100644 apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts create mode 100644 apps/buddy/service/src/plugins/capabilityLedgerFile.ts create mode 100644 apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts create mode 100644 apps/buddy/shared/extensions/extensionCapabilities.ts create mode 100644 apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionUninstallDialog.spec.ts diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index eedebfc0e..191fc2c06 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -20,6 +20,7 @@ import { extensionActionRpc } from '../../../shared/extensions/extensionActionAp import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent' import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring' +import { extensionCapabilitiesRpc, extensionCapabilityImpactSchema } from '../../../shared/extensions/extensionCapabilities' import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext' import { extensionShellRpc } from '../../../shared/extensions/extensionShell' import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills' @@ -144,6 +145,8 @@ export class DesktopIntegrations { agentChanged: catalog => service.notify(extensionAgentRpc.changed, catalog), shellsChanged: () => service.notify(extensionShellRpc.changed, {}), skillsChanged: () => service.notify(extensionSkillsRpc.changed, {}), + capabilitiesChanged: () => service.notify(extensionCapabilitiesRpc.changed, {}), + capabilityImpact: async extensionId => extensionCapabilityImpactSchema.parse(await service.request(extensionCapabilitiesRpc.impact, { extensionId })), agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: null })), readResource: (target, signal, request) => readSpaceFileResource(target, signal, async input => spaceFilesRpc.locate.response.parse(await service.request(spaceFilesRpc.locate.method, input, { signal })), request), readTaskFileResource: async (target, signal, request) => { @@ -175,14 +178,17 @@ export class DesktopIntegrations { runtime.extensionStorage = extensions runtime.extensionShells = extensions.shells runtime.extensionSkills = extensions.skills + runtime.extensionCapabilities = extensions.capabilities service.notify(extensionShellRpc.changed, {}) service.notify(extensionSkillsRpc.changed, {}) + service.notify(extensionCapabilitiesRpc.changed, {}) this.#subscriptions.push(() => { runtime.inspectExtension = null runtime.extensionAgent = null runtime.extensionStorage = null runtime.extensionShells = null runtime.extensionSkills = null + runtime.extensionCapabilities = null }) this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window)) this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window)) diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index a611cc874..ca4e825a3 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -20,6 +20,7 @@ import { automationNotifications } from '../../../shared/automation/automationAp import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel' import { extensionAgentInvocationSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent' import { extensionAgentResourceRequestSchema } from '../../../shared/extensions/extensionAgentResources' +import { extensionCapabilitiesRpc } from '../../../shared/extensions/extensionCapabilities' import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills' import { networkPermissionGrantSchema, networkPermissionsRpc } from '../../../shared/permissions/networkPermissionsRpc' import { isLinux } from '../../../shared/platform/identifiers' @@ -50,6 +51,7 @@ export class DesktopRuntimeHost { extensionAgent: ReturnType['agent'] | null = null extensionShells: ReturnType['shells'] | null = null extensionSkills: ReturnType['skills'] | null = null + extensionCapabilities: ReturnType['capabilities'] | null = null extensionStorage: Pick, 'withStoppedStorage'> | null = null readonly contextPanel: ContextPanelHost readonly configStore: LexoraConfigStore @@ -221,6 +223,12 @@ export class DesktopRuntimeHost { throw new Error('EXTENSION_AGENT_UNAVAILABLE') return this.extensionAgent.readResource(extensionAgentResourceRequestSchema.parse(input), signal ?? new AbortController().signal) }), + // Unlike skills, an empty answer here would read as "everything was uninstalled", so an unready service must fail instead. + peer.onRequest(extensionCapabilitiesRpc.list, () => { + if (!this.extensionCapabilities) + throw new Error('EXTENSION_SERVICE_UNAVAILABLE') + return this.extensionCapabilities.list() + }), peer.onRequest(extensionAgentRpc.invoke, (input, signal) => { if (!this.extensionAgent) throw new Error('EXTENSION_AGENT_UNAVAILABLE') diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 5fdaba0b3..8d84c64b0 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -6,6 +6,7 @@ import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskAction import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' +import type { ExtensionCapabilityImpact } from '../../../shared/extensions/extensionCapabilities' import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext' import type { TaskFileTarget } from '../../../shared/files/taskFileApi' import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi' @@ -43,11 +44,13 @@ export function registerExtensionIpc(options: { invokeTaskAction: (input: ExtensionTaskActionInput) => Promise shellsChanged?: () => void skillsChanged?: () => void + capabilitiesChanged?: () => void + capabilityImpact?: (extensionId: string) => Promise agentChanged?: (catalog: ExtensionAgentCatalog) => void record?: ApplicationDiagnosticReporter conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise -}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { +}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, capabilities: { list: ExtensionService['capabilityContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version) async function reviewResult(operation: () => Promise): Promise { try { @@ -73,6 +76,7 @@ export function registerExtensionIpc(options: { clipboard: createExtensionClipboard(options.getWindow), shellsChanged: options.shellsChanged, skillsChanged: options.skillsChanged, + capabilitiesChanged: options.capabilitiesChanged, themes: options.themes, createHost: (pkg, broker, failed) => { const host = new SandboxedExtensionHost(pkg, protocol, broker, failed) @@ -281,6 +285,7 @@ export function registerExtensionIpc(options: { case 'restart': return await service.restart(input.id) case 'applyUpdate': return await service.applyUpdate(input.id) case 'uninstall': return await service.uninstall(input.id, input.clearData) + case 'capabilityImpact': return await (options.capabilityImpact?.(input.id) ?? { modified: [] }) case 'devtools': return await service.devtools(input.id) case 'revokeResources': return await service.revokeResources(input.id) case 'execute': return await service.execute(input.id, input.command, input.resource) @@ -352,6 +357,10 @@ export function registerExtensionIpc(options: { await prepared return service.skillContributions() } }, + capabilities: { list: async () => { + await prepared + return service.capabilityContributions() + } }, shells: { list: async () => { await prepared return service.shellContributions() diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 865571f34..5a6ffffb5 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -32,6 +32,7 @@ export function createExtensionApi(): ExtensionApi { restart: id => request({ action: 'restart', id }), applyUpdate: id => request({ action: 'applyUpdate', id }), uninstall: (id, options) => request({ action: 'uninstall', id, clearData: options?.clearData ?? false }), + capabilityImpact: id => request({ action: 'capabilityImpact', id }), devtools: id => request({ action: 'devtools', id }), revokeResources: id => request({ action: 'revokeResources', id }), execute: (id, command, resource) => request({ action: 'execute', id, command, resource }), diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 537626624..0daca1610 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -2,6 +2,7 @@ import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../s import type { ExtensionAgentResourceRequest, ExtensionAgentResourceResponse } from '../../shared/extensions/extensionAgentResources' import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' +import type { ExtensionCapabilityDescriptor } from '../../shared/extensions/extensionCapabilities' import type { ExtensionClipboardData } from '../../shared/extensions/extensionClipboard' import type { ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext' import type { ExtensionConditionState } from '../../shared/extensions/extensionConditions' @@ -90,6 +91,7 @@ export interface ExtensionServicePorts { agentChanged?: () => void shellsChanged?: () => void skillsChanged?: () => void + capabilitiesChanged?: () => void conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise notify?: (id: string, notification: { title: string, body: string }) => boolean @@ -184,8 +186,10 @@ export class ExtensionService { workbench: () => this.#panes.map(({ id, active, visible }) => ({ id, active, visible })), }) this.onDidChange((change) => { - if (change.kind === 'package' || change.kind === 'enabled') + if (change.kind === 'package' || change.kind === 'enabled') { ports.skillsChanged?.() + ports.capabilitiesChanged?.() + } if (change.kind === 'package' || change.kind === 'enabled' || (change.kind === 'configuration' && change.application.status === 'committed')) this.#refreshShellFingerprint() if (change.kind === 'resources-revoked') { @@ -398,6 +402,26 @@ export class ExtensionService { }) } + /** MCP connectors and sub-agents that installed packages declare; the service process turns them into its own records. */ + async capabilityContributions(): Promise { + await this.initialize() + await this.#mutating + if (this.#disposed) + throw new Error('EXTENSION_SERVICE_UNAVAILABLE') + return Object.entries(this.store.installed).flatMap(([extensionId, record]) => { + const { manifest, revision } = record.current + const { mcpConnectors, subagents } = manifest.contributes + if (!mcpConnectors.length && !subagents.length) + return [] + let enabled = record.enabled + try { + this.#order(extensionId) + } + catch { enabled = false } + return [{ extensionId, extensionName: manifest.name, revision, enabled, mcpConnectors: structuredClone(mcpConnectors), subagents: structuredClone(subagents) }] + }) + } + async #readAgentContributions(): Promise { if (this.#disposed) return [] diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 2767d2922..24ac77b75 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -1,4 +1,5 @@ import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ExtensionMcpConnectorContribution, ExtensionSubagentContribution } from '../../../shared/extensions/extensionCapabilities' import type { ExtensionHostFailure } from '../../../shared/extensions/extensionDiagnostic' import type { ExtensionLocalFile } from '../../../shared/extensions/extensionResources' import type { JsonValue } from '../../../shared/workbench/workbenchState' @@ -837,3 +838,31 @@ it('denies clipboard and document metadata to decoration views despite declared for (const [method, data] of [['clipboard.read', null], ['clipboard.write', { text: 'denied' }], ['view.setDocumentInfo', { title: 'denied', dirty: false }]] as const) await expect(service.viewRequest(session.id, session.generation, session.token, method, data)).rejects.toThrow('EXTENSION_METHOD_DENIED') }) + +const connector: ExtensionMcpConnectorContribution = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', deferred: true, credential: 'bearer' } +const subagent: ExtensionSubagentContribution = { id: 'law-finder', kind: 'npc', name: 'Law finder', description: '', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [JSON.stringify(['mcp', 'legal-search', 'legal__search'])] }, fileAccess: 'read_only' } + +it('publishes declared MCP connectors and sub-agents without activating hosts and follows package state', async () => { + const value = manifest({ id: 'tests.legal', apiVersion: 4, entry: undefined, permissions: {}, contributes: { mcpConnectors: [connector], subagents: [subagent] } }) + const { service, hosts } = await fixture({}, value) + const [descriptor] = await service.capabilityContributions() + expect(descriptor).toMatchObject({ extensionId: 'tests.legal', extensionName: value.name, enabled: true, mcpConnectors: [{ id: 'legal-search', url: 'https://legal.example.com/mcp', credential: 'bearer', deferred: true }], subagents: [{ id: 'law-finder', kind: 'npc' }] }) + await service.enable('tests.legal', false) + expect(await service.capabilityContributions()).toEqual([{ ...descriptor, enabled: false }]) + await service.enable('tests.legal', true) + expect(await service.capabilityContributions()).toEqual([descriptor]) + await service.uninstall('tests.legal') + expect(await service.capabilityContributions()).toEqual([]) + expect(hosts).toHaveLength(0) +}) + +it('announces capability changes when packages are installed, switched or removed', async () => { + const capabilitiesChanged = vi.fn() + const value = manifest({ id: 'tests.legal', apiVersion: 4, entry: undefined, permissions: {}, contributes: { mcpConnectors: [connector] } }) + const { service } = await fixture({ capabilitiesChanged }, value) + await service.initialize() + capabilitiesChanged.mockClear() + await service.enable('tests.legal', false) + await service.uninstall('tests.legal') + expect(capabilitiesChanged).toHaveBeenCalledTimes(2) +}) diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 569ed69fe..2497d2655 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -35,6 +35,8 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 需要随插件提供可搜索、按需读取的工作方法或资源时,读取 [插件 Skills](references/skills.md)。使用标准 `SKILL.md` 与 `contributes.skills`,纯 Skills 包不需要脚本入口;工具仍通过 Agent 能力单独声明。 +需要随插件提供 MCP 连接或子代理时,读取 [MCP 连接与子代理](references/mcp-and-subagents.md)。它们是无需脚本入口的纯声明贡献,安装后默认关闭连接且不含凭据。 + 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 需要实时配置、表单依赖或运行状态判断时,读取 [条件函数](references/conditions.md),声明数据输入与作用域,不将函数序列化进清单。需要 Agent 指令、后台任务动作、模型调用、任务标题或原生设置时,追加读取 [Agent 与设置](references/agent-settings.md),并确认当前宿主能力目录支持。事件驱动动作与订阅按 [事件与触发列表](references/events.md) 选择精确入口和载荷,不把普通事件监听器当作任务能力调用。 2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index 70f3a0530..e49aac3cb 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -52,6 +52,37 @@ export interface NavigationContribution { } /** API 4: contributes.skills; path points to a packaged Skill directory's SKILL.md. */ export interface SkillContribution { id: string, path: string } +/** + * API 4: contributes.mcpConnectors; adds one MCP connector record when the package is installed. The record is created + * disabled and without credentials, nothing is downloaded or run, and the user enables it (after entering a token when + * `credential` asks for one) in settings. `id` is the connector ID, not prefixed with the plugin ID. + */ +export interface McpConnectorContribution { + id: string + name: string + transport: 'streamable-http' + /** HTTPS address without embedded credentials. */ + url: string + /** Defaults to true: tools are discovered on demand instead of being offered to the model directly. */ + deferred?: boolean + /** Defaults to 'none'. Credentials never travel with the package. */ + credential?: 'none' | 'bearer' | 'oauth' +} +/** + * API 4: contributes.subagents; adds one NPC sub-agent when the package is installed and lists it for delegation by the + * default companion. Only `kind: 'npc'` is accepted. Selected tool IDs are JSON `[source, group, name]` strings; an + * `mcp` source must name a connector declared in the same package's `mcpConnectors`. + */ +export interface SubagentContribution { + id: string + kind: 'npc' + name: string + description?: string + instructions: string + tools: { mode: 'all' | 'none' } | { mode: 'selected', ids: string[] } + /** Defaults to 'read_only'. */ + fileAccess?: 'inherit' | 'read_only' +} export interface Resource { readonly id: string, readonly name: string, readonly sourceId?: string } export interface ResourceTransferOptions { name?: string, signal?: AbortSignal } export interface ResourceApi { diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md new file mode 100644 index 000000000..1b121424f --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md @@ -0,0 +1,48 @@ +# 随插件提供 MCP 连接与子代理 + +API 4 的 `contributes.mcpConnectors` 与 `contributes.subagents` 让一个纯声明包(无 `entry`、无权限)向应用添加 MCP 连接记录和 NPC 子代理;技能沿用 `contributes.skills`(见 [插件 Skills](skills.md))。三者可以放在同一个包里。 + +```json +{ + "apiVersion": 4, + "contributes": { + "mcpConnectors": [ + { "id": "legal-search", "name": "法规检索", "transport": "streamable-http", "url": "https://legal.example.com/mcp", "credential": "bearer" } + ], + "subagents": [ + { + "id": "law-finder", + "kind": "npc", + "name": "法规检索员", + "description": "按问题检索并核对法规条文", + "instructions": "先检索法规原文,再返回条文编号与出处;不要改动用户文件。", + "tools": { "mode": "selected", "ids": ["[\"mcp\",\"legal-search\",\"legal__search\"]"] }, + "fileAccess": "read_only" + } + ] + } +} +``` + +## MCP 连接 + +- 只允许 `streamable-http` 与不带账号密码的 HTTPS 地址;不支持本机命令启动(`stdio`)。 +- 安装只添加一条连接记录:不下载或运行任何代码,默认关闭,不含凭据。`credential` 为 `bearer` 或 `oauth` 时,用户需在设置中填写 Token 或登录后才能启用;包永远不携带凭据。 +- `deferred`(默认 `true`)决定工具按需发现还是直接提供给模型。 +- 连接 `id` 为小写字母、数字、`-`、`_`,直接作为连接器 ID,不加插件 ID 前缀。若用户已有同 ID 的连接,宿主不会接管它。 +- 最多 16 个。 + +## 子代理 + +- 只允许 `kind: "npc"`。`id` 同样不带插件 ID 前缀,宿主会存为 `ext.<插件 ID>.`,以免与用户自建或内置子代理重名。不要使用内置 ID(`npc-explorer`、`npc-researcher`、`npc-executor`、`npc-reviewer`)。 +- 安装后自动加入默认伙伴的可委派列表;卸载时移除。 +- `tools`:`all`、`none` 或 `selected`。`selected` 的工具 ID 是 JSON 字符串 `["来源","分组","名称"]`;来源为 `mcp` 的工具必须指向同一个包 `mcpConnectors` 里声明的连接,否则清单无效,保证被引用的 MCP 随包一起安装。 +- `fileAccess` 默认 `read_only`;`inherit` 表示与当前任务的文件权限一致,不会高于用户授权。 +- 最多 16 个。 + +## 启停、卸载与用户修改 + +- 插件被停用时,它添加的连接和子代理一并关闭;重新启用后恢复。 +- 卸载包会删除没被用户改过的连接记录和子代理。用户改过的内容(子代理的名称、说明、提示词、工具或文件权限;连接的名称、地址、暴露方式,或已填写的凭据)会保留,成为用户自己的;卸载对话框会提前列出。 +- 包升级只更新没被改过的记录。 +- 用户在安装后自己删除的连接或子代理,不会在下次同步时被装回,直到重新安装整个插件。 diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 0ba892de2..653f2c702 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -30,6 +30,7 @@ import { Emitter } from '../../shared/events/Emitter' import { executionsChanged } from '../../shared/executions/executionPresentationApi' import { extensionActionRpc } from '../../shared/extensions/extensionActionApi' import { extensionAgentRpc } from '../../shared/extensions/extensionAgent' +import { extensionCapabilitiesRpc, extensionCapabilityDescriptorSchema, extensionCapabilityImpactRpc } from '../../shared/extensions/extensionCapabilities' import { extensionShellRpc } from '../../shared/extensions/extensionShell' import { extensionSkillDescriptorSchema, extensionSkillsRpc } from '../../shared/extensions/extensionSkills' import { ServiceHost } from '../../shared/lifecycle/ServiceHost' @@ -146,9 +147,11 @@ import { NetworkPermissionsClient } from './permissions/NetworkPermissionsClient import { observePetActionDiagnostics } from './pet/observePetActionDiagnostics' import { PetActionService } from './pet/PetActionService' import { bindExtensionActions } from './plugins/bindExtensionActions' +import { createCapabilityLedgerFile } from './plugins/capabilityLedgerFile' import { ExtensionActionService } from './plugins/ExtensionActionService' import { ExtensionAgentRuntime } from './plugins/ExtensionAgentRuntime' import { createExtensionResourceCapabilities } from './plugins/extensionResourceCapabilities' +import { ExtensionCapabilitySync } from './plugins/ExtensionCapabilitySync' import { observeExtensionAgentDiagnostics } from './plugins/observeExtensionAgentDiagnostics' import { PluginAuthoringService } from './plugins/PluginAuthoringService' import { registerExtensionConditionRpc } from './plugins/registerExtensionConditionRpc' @@ -760,6 +763,27 @@ export async function startBuddyService( connectorService.start() return consumer }) + await host.start('runtime.extension_capabilities', ({ defer }) => { + const sync = new ExtensionCapabilitySync({ + buddies, + connectors: connectorService, + ledger: createCapabilityLedgerFile(join(options.buddyHome, 'extension-capabilities.json')), + descriptors: async () => extensionCapabilityDescriptorSchema.array().parse(await options.rpc.request(extensionCapabilitiesRpc.list, {})), + report: event => record(event), + }) + const impact = registerRuntimeRequest(options.rpc, extensionCapabilityImpactRpc, input => sync.impact(input.extensionId)) + defer(options.rpc.onNotification((method) => { + if (method === extensionCapabilitiesRpc.changed) + void sync.schedule() + })) + defer(async () => { + impact() + await sync.dispose() + }) + // The host announces itself once its extensions are loaded; this first pass only covers a service restarted on its own. + void sync.schedule() + return sync + }, ['runtime.buddies', 'runtime.connectors']) const browserHost = new BrowserHostClient(options.rpc) const webSettings = await host.start('runtime.web_settings', ({ defer }) => { const service = new WebSettingsService(workspace, options.rpc) diff --git a/apps/buddy/service/src/buddies/BuddiesService.ts b/apps/buddy/service/src/buddies/BuddiesService.ts index 4342007bb..4b5143e20 100644 --- a/apps/buddy/service/src/buddies/BuddiesService.ts +++ b/apps/buddy/service/src/buddies/BuddiesService.ts @@ -55,6 +55,11 @@ export class BuddiesService { return this.#changeProfile(() => this.#repository.update(request)) } + installManagedNpc(input: Parameters[0], signal?: AbortSignal) { + this.#check(signal) + return this.#changeProfile(() => this.#repository.upsertManagedNpc(input)) + } + delete(input: BuddyMutation, signal?: AbortSignal) { this.#check(signal) const request = parse(buddyMutationSchema, input) diff --git a/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts new file mode 100644 index 000000000..577404ea4 --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts @@ -0,0 +1,287 @@ +import type { BuddyMutation, BuddyUpdate, LocalBuddy } from '../../../shared/buddies/buddyApi' +import type { BuddySettings } from '../../../shared/buddies/buddySettings' +import type { ExtensionCapabilityDescriptor, ExtensionCapabilityImpact, ExtensionMcpConnectorContribution, ExtensionSubagentContribution } from '../../../shared/extensions/extensionCapabilities' +import type { McpServerConfig } from '../connectors/mcp/mcpSchemas' +import type { McpServerRecord } from '../storage/connectorRepository' +import { createHash } from 'node:crypto' +import { BUILTIN_BUDDY_ID } from '../../../shared/buddies/buddyApi' +import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings' +import { BUILTIN_SUBAGENT_IDS, connectorToolExposure, extensionSubagentId } from '../../../shared/extensions/extensionCapabilities' + +/** What the sync remembers about a record it created, so it can tell the user's edits from its own definition. */ +export interface CapabilityLedgerEntry { + extensionId: string + kind: 'mcp' | 'agent' + /** Connector id, or the sub-agent row id. */ + id: string + /** Digest of the definition as installed. A record whose current digest differs was edited by the user. */ + digest: string + /** The package is disabled and the sync switched the record off; enabling the package lifts this. */ + suspended?: boolean +} +export interface CapabilityLedger { version: 1, entries: CapabilityLedgerEntry[] } + +export interface CapabilityLedgerStore { + read: () => Promise + write: (ledger: CapabilityLedger) => Promise +} +export interface CapabilityBuddies { + get: (buddyId: string) => LocalBuddy + update: (input: BuddyUpdate) => Promise + delete: (input: BuddyMutation) => LocalBuddy + installManagedNpc: (input: { id: string, name: string, description: string, settings: BuddySettings }) => LocalBuddy +} +export interface CapabilityConnectors { + list: () => readonly McpServerRecord[] + upsert: (config: McpServerConfig) => Promise + setEnabled: (id: string, enabled: boolean) => Promise + remove: (id: string) => Promise +} +export interface ExtensionCapabilitySyncOptions { + buddies: CapabilityBuddies + connectors: CapabilityConnectors + ledger: CapabilityLedgerStore + descriptors: () => Promise + report?: (event: { event: string, level: 'info' | 'warn', errorCode?: string, extensionId?: string, count?: number }) => void +} + +const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex') +export const connectorDigest = (connector: Pick) => digest(['mcp', connector.name, connector.url, connectorToolExposure(connector)]) +export const subagentDigest = (agent: { name: string, description: string, instructions: string, tools: unknown, fileAccess: string }) => digest(['agent', agent.name, agent.description, agent.instructions, agent.tools, agent.fileAccess]) +const connectorRecordDigest = (record: McpServerRecord) => digest(['mcp', record.name, record.url, record.toolExposure === 'direct' ? 'direct' : 'deferred']) +const buddyDigest = (buddy: LocalBuddy) => subagentDigest({ name: buddy.name, description: buddy.description, instructions: buddy.settings.instructions, tools: buddy.settings.tools, fileAccess: buddy.settings.limits.fileAccess }) + +/** + * Turns the MCP connectors and sub-agents that installed packages declare into records in the service's own stores, + * and takes them out again when the package goes. Nothing is downloaded or executed: a connector is a disabled record + * without credentials, a sub-agent is a row bound to the default companion's delegation list. + */ +export class ExtensionCapabilitySync { + readonly #options: ExtensionCapabilitySyncOptions + #running: Promise | undefined + #again = false + #disposed = false + + constructor(options: ExtensionCapabilitySyncOptions) { + this.#options = options + } + + /** Coalesces requests: a change that arrives while a pass is running triggers one more pass afterwards. */ + schedule(): Promise { + if (this.#disposed) + return Promise.resolve() + if (this.#running) { + this.#again = true + return this.#running + } + const run = (async () => { + try { + do { + this.#again = false + await this.reconcile().catch(error => this.#options.report?.({ event: 'extensions.capabilities.reconcile_failed', level: 'warn', errorCode: errorCode(error) })) + } while (this.#again && !this.#disposed) + } + finally { + this.#running = undefined + } + })() + this.#running = run + return run + } + + async dispose(): Promise { + this.#disposed = true + await this.#running + } + + /** Items the user changed since they were installed; uninstalling their package keeps these. */ + async impact(extensionId: string): Promise { + const { entries } = await this.#options.ledger.read() + const modified: ExtensionCapabilityImpact['modified'] = [] + for (const entry of entries.filter(entry => entry.extensionId === extensionId)) { + const current = this.#current(entry) + if (current && current.modified) + modified.push({ kind: entry.kind, name: current.name }) + } + return { modified } + } + + async reconcile(): Promise { + // A failed read must leave everything alone: an empty answer would mean "everything was uninstalled". + const descriptors = await this.#options.descriptors() + const ledger = await this.#options.ledger.read() + const kept = new Map(ledger.entries.map(entry => [key(entry), entry])) + const wanted = new Set() + for (const descriptor of descriptors) { + for (const connector of descriptor.mcpConnectors) { + wanted.add(key({ kind: 'mcp', id: connector.id })) + await this.#guard(descriptor, () => this.#connector(descriptor, connector, kept)) + } + for (const subagent of descriptor.subagents) { + wanted.add(key({ kind: 'agent', id: extensionSubagentId(descriptor.extensionId, subagent.id) })) + await this.#guard(descriptor, () => this.#subagent(descriptor, subagent, kept)) + } + } + for (const [entryKey, entry] of [...kept]) { + if (wanted.has(entryKey)) + continue + try { + await this.#remove(entry) + kept.delete(entryKey) + } + catch (error) { + this.#options.report?.({ event: 'extensions.capabilities.remove_failed', level: 'warn', extensionId: entry.extensionId, errorCode: errorCode(error) }) + } + } + await this.#options.ledger.write({ version: 1, entries: [...kept.values()] }) + } + + async #guard(descriptor: ExtensionCapabilityDescriptor, operation: () => Promise): Promise { + try { + await operation() + } + catch (error) { + this.#options.report?.({ event: 'extensions.capabilities.install_failed', level: 'warn', extensionId: descriptor.extensionId, errorCode: errorCode(error) }) + } + } + + async #connector(descriptor: ExtensionCapabilityDescriptor, connector: ExtensionMcpConnectorContribution, kept: Map) { + const entryKey = key({ kind: 'mcp', id: connector.id }) + const entry = kept.get(entryKey) + if (entry && entry.extensionId !== descriptor.extensionId) { + this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId }) + return + } + const existing = this.#options.connectors.list().find(record => record.id === connector.id) + const config = (enabled: boolean, credentialRef: string | null): McpServerConfig => ({ id: connector.id, name: connector.name, transport: 'streamable-http', url: connector.url, toolExposure: connectorToolExposure(connector), enabled, credentialRef }) + if (!entry) { + // An unknown connector with the same id is the user's; never take it over. + if (existing) { + this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId }) + return + } + await this.#options.connectors.upsert(config(false, null)) + kept.set(entryKey, { extensionId: descriptor.extensionId, kind: 'mcp', id: connector.id, digest: connectorDigest(connector) }) + return + } + // The user removed it after installing: leave it removed until the package itself is reinstalled. + if (!existing) + return + const next = connectorDigest(connector) + if (entry.digest !== next && connectorRecordDigest(existing) === entry.digest && (!existing.credentialRef || existing.url === connector.url)) { + await this.#options.connectors.upsert(config(existing.enabled, existing.credentialRef)) + entry.digest = next + } + if (!descriptor.enabled && existing.enabled) { + await this.#options.connectors.setEnabled(existing.id, false) + entry.suspended = true + } + else if (descriptor.enabled && entry.suspended) { + delete entry.suspended + } + } + + async #subagent(descriptor: ExtensionCapabilityDescriptor, subagent: ExtensionSubagentContribution, kept: Map) { + const id = extensionSubagentId(descriptor.extensionId, subagent.id) + if ((BUILTIN_SUBAGENT_IDS as readonly string[]).includes(subagent.id)) + return + const entryKey = key({ kind: 'agent', id }) + const entry = kept.get(entryKey) + const existing = this.#buddy(id) + const definition = { id, name: subagent.name, description: subagent.description, settings: subagentSettings(subagent) } + if (!entry) { + if (existing && !existing.deletedAt) { + this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId }) + return + } + this.#options.buddies.installManagedNpc(definition) + await this.#delegate(id, true) + kept.set(entryKey, { extensionId: descriptor.extensionId, kind: 'agent', id, digest: subagentDigest(subagent) }) + return + } + // Removed by the user after installing: leave it removed until the package itself is reinstalled. + if (!existing || existing.deletedAt) + return + const next = subagentDigest(subagent) + if (entry.digest !== next && buddyDigest(existing) === entry.digest) { + this.#options.buddies.installManagedNpc(definition) + entry.digest = next + } + const current = this.#buddy(id) + if (current && !descriptor.enabled && current.enabled) { + await this.#options.buddies.update({ buddyId: id, expectedRevision: current.revision, enabled: false }) + entry.suspended = true + } + else if (current && descriptor.enabled && entry.suspended) { + if (!current.enabled) + await this.#options.buddies.update({ buddyId: id, expectedRevision: current.revision, enabled: true }) + delete entry.suspended + } + } + + async #remove(entry: CapabilityLedgerEntry): Promise { + const current = this.#current(entry) + // Already gone, or edited by the user: keep it as the user's own instead of deleting their work. + if (!current || current.modified) + return + if (entry.kind === 'mcp') { + await this.#options.connectors.remove(entry.id) + return + } + await this.#delegate(entry.id, false) + const buddy = this.#buddy(entry.id) + if (buddy && !buddy.deletedAt) + this.#options.buddies.delete({ buddyId: buddy.id, expectedRevision: buddy.revision }) + } + + #current(entry: CapabilityLedgerEntry): { name: string, modified: boolean } | null { + if (entry.kind === 'mcp') { + const record = this.#options.connectors.list().find(record => record.id === entry.id) + return record ? { name: record.name, modified: !!record.credentialRef || connectorRecordDigest(record) !== entry.digest } : null + } + const buddy = this.#buddy(entry.id) + return buddy && !buddy.deletedAt ? { name: buddy.name, modified: buddyDigest(buddy) !== entry.digest } : null + } + + #buddy(id: string): LocalBuddy | null { + try { + return this.#options.buddies.get(id) + } + catch (error) { + if (errorCode(error) === 'BUDDY_NOT_FOUND') + return null + throw error + } + } + + /** Puts a sub-agent into, or takes it out of, the default companion's delegation list. */ + async #delegate(npcId: string, bound: boolean): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const actor = this.#options.buddies.get(BUILTIN_BUDDY_ID) + const present = actor.settings.npcIds.includes(npcId) + if (present === bound) + return + const npcIds = bound ? [...actor.settings.npcIds, npcId] : actor.settings.npcIds.filter(id => id !== npcId) + try { + await this.#options.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds }) }) + return + } + catch (error) { + if (errorCode(error) !== 'BUDDY_CONFLICT' || attempt === 2) + throw error + } + } + } +} + +function subagentSettings(subagent: ExtensionSubagentContribution): BuddySettings { + return { ...structuredClone(DEFAULT_BUDDY_SETTINGS), instructions: subagent.instructions, tools: structuredClone(subagent.tools), limits: { fileAccess: subagent.fileAccess } } +} + +function key(entry: Pick): string { + return `${entry.kind}:${entry.id}` +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && error && 'code' in error && typeof error.code === 'string' ? error.code : 'EXTENSION_CAPABILITY_FAILED' +} diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts new file mode 100644 index 000000000..1f3905c4b --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts @@ -0,0 +1,226 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ExtensionCapabilityDescriptor } from '../../../../shared/extensions/extensionCapabilities' +import type { McpServerConfig } from '../../connectors/mcp/mcpSchemas' +import type { McpServerRecord } from '../../storage/connectorRepository' +import type { CapabilityLedger } from '../ExtensionCapabilitySync' +import { afterEach, describe, expect, it } from 'vitest' +import { BUILTIN_BUDDY_ID } from '../../../../shared/buddies/buddyApi' +import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings' +import { BUILTIN_SUBAGENT_IDS, extensionCapabilityDescriptorSchema, extensionSubagentId } from '../../../../shared/extensions/extensionCapabilities' +import { BuddiesService } from '../../buddies/BuddiesService' +import { createBuddyRepository } from '../../storage/buddyRepository' +import { openBuddyDatabase } from '../../storage/database' +import { ExtensionCapabilitySync } from '../ExtensionCapabilitySync' + +const extensionId = 'tests.legal' + +let database: DatabaseSync +let buddies: BuddiesService +let records: McpServerRecord[] +let ledger: CapabilityLedger +let descriptors: ExtensionCapabilityDescriptor[] +let events: string[] +let failure: Error | null + +function descriptor(patch: Partial = {}): ExtensionCapabilityDescriptor { + return extensionCapabilityDescriptorSchema.parse({ + extensionId, + extensionName: 'Legal pack', + revision: 'a', + enabled: true, + mcpConnectors: [{ id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', credential: 'bearer' }], + subagents: [{ id: 'law-finder', kind: 'npc', name: 'Law finder', description: 'Finds statutes', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [JSON.stringify(['mcp', 'legal-search', 'legal__search'])] }, fileAccess: 'read_only' }], + ...patch, + }) +} + +function setup() { + database = openBuddyDatabase({ databasePath: ':memory:' }) + buddies = new BuddiesService(createBuddyRepository(database)) + records = [] + ledger = { version: 1, entries: [] } + descriptors = [] + events = [] + failure = null + const connectors = { + list: () => records, + async upsert(config: McpServerConfig) { + const existing = records.find(record => record.id === config.id) + const record: McpServerRecord = { id: config.id, name: config.name, toolNamespace: config.id, toolExposure: config.toolExposure ?? 'deferred', transport: 'streamable-http', command: null, args: null, cwd: null, url: 'url' in config ? config.url : null, credentialRef: config.credentialRef, executionConfirmedAt: null, enabled: config.enabled, createdAt: existing?.createdAt ?? 'now', updatedAt: 'now' } + records = [...records.filter(item => item.id !== config.id), record] + return record + }, + async setEnabled(id: string, enabled: boolean) { + const record = { ...records.find(record => record.id === id)!, enabled } + records = records.map(item => item.id === id ? record : item) + return record + }, + async remove(id: string) { + const before = records.length + records = records.filter(record => record.id !== id) + return records.length !== before + }, + } + return new ExtensionCapabilitySync({ + buddies, + connectors, + ledger: { read: async () => structuredClone(ledger), write: async (value) => { ledger = structuredClone(value) } }, + descriptors: async () => { + if (failure) + throw failure + return structuredClone(descriptors) + }, + report: event => events.push(event.event), + }) +} + +const rowId = extensionSubagentId(extensionId, 'law-finder') +const npcIds = () => buddies.get(BUILTIN_BUDDY_ID).settings.npcIds + +afterEach(() => { + buddies?.dispose() + database?.close() +}) + +describe('installing extension capabilities', () => { + it('adds a disabled connector record and a prefixed sub-agent the default companion can delegate to', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + expect(records).toMatchObject([{ id: 'legal-search', name: 'Legal search', url: 'https://legal.example.com/mcp', enabled: false, credentialRef: null, toolExposure: 'deferred' }]) + expect(buddies.get(rowId)).toMatchObject({ kind: 'npc', enabled: true, name: 'Law finder', description: 'Finds statutes', settings: { instructions: 'Search statutes first.', limits: { fileAccess: 'read_only' }, tools: { mode: 'selected' } } }) + expect(rowId.startsWith('ext.')).toBe(true) + expect(npcIds()).toContain(rowId) + expect(ledger.entries.map(entry => entry.kind).sort()).toEqual(['agent', 'mcp']) + }) + + it('does nothing more on a second pass and respects the user removing the sub-agent from the delegation list or deleting it', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + const actor = buddies.get(BUILTIN_BUDDY_ID) + await buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds: actor.settings.npcIds.filter(id => id !== rowId) }) }) + const row = buddies.get(rowId) + await sync.reconcile() + expect(buddies.get(rowId)).toEqual(row) + expect(npcIds()).not.toContain(rowId) + buddies.delete({ buddyId: rowId, expectedRevision: row.revision }) + records = [] + await sync.reconcile() + expect(buddies.get(rowId).deletedAt).not.toBeNull() + expect(records).toEqual([]) + }) + + it('leaves a connector or sub-agent that already uses the id alone', async () => { + const sync = setup() + records = [{ id: 'legal-search', name: 'Mine', toolNamespace: 'mine', toolExposure: 'direct', transport: 'streamable-http', command: null, args: null, cwd: null, url: 'https://mine.example.com/mcp', credentialRef: 'legal-search', executionConfirmedAt: null, enabled: true, createdAt: 'then', updatedAt: 'then' }] + buddies.installManagedNpc({ id: rowId, name: 'Mine', description: '', settings: buddySettingsSchema.parse(buddies.get('npc-explorer').settings) }) + descriptors = [descriptor()] + await sync.reconcile() + expect(records).toMatchObject([{ name: 'Mine', url: 'https://mine.example.com/mcp', enabled: true }]) + expect(buddies.get(rowId).name).toBe('Mine') + expect(ledger.entries).toEqual([]) + expect(events.filter(event => event === 'extensions.capabilities.conflict')).toHaveLength(2) + }) + + it('never recreates the built-in sub-agents', async () => { + const sync = setup() + const builtin = descriptor() + builtin.subagents[0]!.id = 'npc-explorer' + descriptors = [builtin] + await sync.reconcile() + expect(() => buddies.get(extensionSubagentId(extensionId, 'npc-explorer'))).toThrow() + expect(ledger.entries).toEqual([]) + }) + + it('keeps the built-in registry in step with what ships in the application', () => { + setup() + const shipped = buddies.list().buddies.filter(buddy => buddy.kind === 'npc').map(buddy => buddy.id).sort() + expect([...BUILTIN_SUBAGENT_IDS].sort()).toEqual(shipped) + }) +}) + +describe('keeping packages and records in step', () => { + it('removes untouched records and the delegation entry on uninstall, and brings the sub-agent back on reinstall', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + descriptors = [] + await sync.reconcile() + expect(records).toEqual([]) + expect(buddies.get(rowId).deletedAt).not.toBeNull() + expect(npcIds()).not.toContain(rowId) + expect(ledger.entries).toEqual([]) + descriptors = [descriptor()] + await sync.reconcile() + expect(buddies.get(rowId)).toMatchObject({ deletedAt: null, enabled: true }) + expect(npcIds()).toContain(rowId) + expect(records).toHaveLength(1) + }) + + it('keeps what the user changed and reports it before the uninstall', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + const row = buddies.get(rowId) + await buddies.update({ buddyId: rowId, expectedRevision: row.revision, settings: buddySettingsSchema.parse({ ...row.settings, instructions: 'My own rules.' }) }) + records = records.map(record => ({ ...record, credentialRef: 'legal-search', enabled: true })) + expect((await sync.impact(extensionId)).modified).toEqual([{ kind: 'mcp', name: 'Legal search' }, { kind: 'agent', name: 'Law finder' }]) + descriptors = [] + await sync.reconcile() + expect(records).toHaveLength(1) + expect(buddies.get(rowId)).toMatchObject({ deletedAt: null, settings: { instructions: 'My own rules.' } }) + expect(ledger.entries).toEqual([]) + expect((await sync.impact(extensionId)).modified).toEqual([]) + }) + + it('applies a package update to untouched records only', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + const updated = descriptor({ revision: 'b' }) + updated.mcpConnectors[0]!.url = 'https://legal.example.com/v2/mcp' + updated.subagents[0]!.instructions = 'Search statutes, then cases.' + descriptors = [updated] + await sync.reconcile() + expect(records[0]).toMatchObject({ url: 'https://legal.example.com/v2/mcp', enabled: false }) + expect(buddies.get(rowId).settings.instructions).toBe('Search statutes, then cases.') + const edited = buddies.get(rowId) + await buddies.update({ buddyId: rowId, expectedRevision: edited.revision, name: 'Renamed' }) + const third = descriptor({ revision: 'c' }) + third.subagents[0]!.instructions = 'Third version.' + descriptors = [third] + await sync.reconcile() + expect(buddies.get(rowId)).toMatchObject({ name: 'Renamed' }) + expect(buddies.get(rowId).settings.instructions).toBe('Search statutes, then cases.') + }) + + it('switches records off while the package is disabled and does not switch them on again by itself', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + await sync.reconcile() + records = records.map(record => ({ ...record, enabled: true })) + descriptors = [descriptor({ enabled: false })] + await sync.reconcile() + expect(records[0]!.enabled).toBe(false) + expect(buddies.get(rowId).enabled).toBe(false) + descriptors = [descriptor({ enabled: true })] + await sync.reconcile() + expect(buddies.get(rowId).enabled).toBe(true) + expect(records[0]!.enabled).toBe(false) + }) + + it('removes nothing when the package list cannot be read', async () => { + const sync = setup() + descriptors = [descriptor()] + await sync.reconcile() + failure = new Error('EXTENSION_SERVICE_UNAVAILABLE') + await expect(sync.reconcile()).rejects.toThrow('EXTENSION_SERVICE_UNAVAILABLE') + await sync.schedule() + expect(records).toHaveLength(1) + expect(buddies.get(rowId).deletedAt).toBeNull() + expect(ledger.entries).toHaveLength(2) + expect(events).toContain('extensions.capabilities.reconcile_failed') + }) +}) diff --git a/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts b/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts new file mode 100644 index 000000000..5e55a4e79 --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts @@ -0,0 +1,27 @@ +import type { CapabilityLedger } from '../ExtensionCapabilitySync' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { createCapabilityLedgerFile } from '../capabilityLedgerFile' + +const directories: string[] = [] +afterEach(async () => { + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +it('round-trips the ledger and treats a missing or damaged file as empty', async () => { + const directory = await mkdtemp(join(tmpdir(), 'lexora-capability-ledger-')) + directories.push(directory) + const path = join(directory, 'extension-capabilities.json') + const store = createCapabilityLedgerFile(path) + expect(await store.read()).toEqual({ version: 1, entries: [] }) + const value: CapabilityLedger = { version: 1, entries: [{ extensionId: 'tests.legal', kind: 'agent', id: 'ext.tests.legal.law-finder', digest: 'a'.repeat(64) }] } + await store.write(value) + expect(await store.read()).toEqual(value) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual(value) + await expect(store.write({ version: 1, entries: [{ ...value.entries[0]!, digest: 'short' }] })).rejects.toThrow() + expect(await store.read()).toEqual(value) + await rm(path) + expect(await store.read()).toEqual({ version: 1, entries: [] }) +}) diff --git a/apps/buddy/service/src/plugins/capabilityLedgerFile.ts b/apps/buddy/service/src/plugins/capabilityLedgerFile.ts new file mode 100644 index 000000000..748a1cd0b --- /dev/null +++ b/apps/buddy/service/src/plugins/capabilityLedgerFile.ts @@ -0,0 +1,44 @@ +import type { CapabilityLedger, CapabilityLedgerStore } from './ExtensionCapabilitySync' +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { z } from 'zod' + +const ledgerSchema = z.object({ + version: z.literal(1), + entries: z.array(z.object({ + extensionId: z.string().min(1).max(120), + kind: z.enum(['mcp', 'agent']), + id: z.string().min(1).max(256), + digest: z.string().regex(/^[a-f0-9]{64}$/), + suspended: z.boolean().optional(), + }).strict()).max(4096), +}).strict() + +/** + * Keeps the ledger next to the other profile data. A missing or unreadable file reads as empty, which is the safe + * direction: with no ledger nothing counts as installed by an extension, so nothing is deleted on uninstall. + */ +export function createCapabilityLedgerFile(path: string): CapabilityLedgerStore { + return { + async read(): Promise { + try { + return ledgerSchema.parse(JSON.parse(await readFile(path, 'utf8'))) + } + catch { + return { version: 1, entries: [] } + } + }, + async write(ledger) { + await mkdir(dirname(path), { recursive: true }) + const temporary = `${path}.${randomUUID()}.tmp` + try { + await writeFile(temporary, JSON.stringify(ledgerSchema.parse(ledger)), { mode: 0o600 }) + await rename(temporary, path) + } + finally { + await rm(temporary, { force: true }) + } + }, + } +} diff --git a/apps/buddy/service/src/storage/buddyRepository.ts b/apps/buddy/service/src/storage/buddyRepository.ts index 505b35801..83dc5c78d 100644 --- a/apps/buddy/service/src/storage/buddyRepository.ts +++ b/apps/buddy/service/src/storage/buddyRepository.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from 'node:sqlite' import type { BuddyCatalog, BuddyCreate, BuddyDefaultUpdate, BuddyMutation, BuddyUpdate, LocalBuddy } from '../../../shared/buddies/buddyApi' +import type { BuddySettings } from '../../../shared/buddies/buddySettings' import { randomUUID } from 'node:crypto' import { buddySchema, BUILTIN_BUDDY_ID } from '../../../shared/buddies/buddyApi' import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings' @@ -88,6 +89,32 @@ export function createBuddyRepository(database: DatabaseSync) { }) }, update: (input: BuddyUpdate) => withTransaction(database, () => updateProfile(input)), + /** + * Inserts or refreshes a sub-agent that an extension package ships. A live row keeps its enabled switch; a row the user + * removed earlier comes back enabled. An id that belongs to an actor is never touched. + */ + upsertManagedNpc(input: { id: string, name: string, description: string, settings: BuddySettings }): LocalBuddy { + return withTransaction(database, () => { + const settings = JSON.stringify(buddySettingsSchema.parse(input.settings)) + if (input.settings.npcIds.length) + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + const now = new Date().toISOString() + const row = database.prepare('SELECT kind, deleted_at AS deletedAt FROM buddies WHERE id = ?').get(input.id) as { kind: string, deletedAt: string | null } | undefined + if (!row) { + database.prepare('INSERT INTO buddies (id, kind, name, description, settings_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(input.id, 'npc', input.name, input.description, settings, now, now) + } + else if (row.kind !== 'npc') { + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + } + else { + database.prepare('UPDATE buddies SET name = ?, description = ?, settings_json = ?, enabled = CASE WHEN deleted_at IS NULL THEN enabled ELSE 1 END, deleted_at = NULL, revision = revision + 1, updated_at = ? WHERE id = ?') + .run(input.name, input.description, settings, now, input.id) + } + changed() + return get(input.id) + }) + }, resetProfile(input: BuddyMutation): LocalBuddy { return withTransaction(database, () => { writable(input) diff --git a/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts b/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts new file mode 100644 index 000000000..84fda91b7 --- /dev/null +++ b/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { extensionCapabilityDescriptorSchema, extensionSubagentId, subagentConnectorReferences } from '../extensionCapabilities' +import { extensionManifestSchema } from '../extensionManifest' + +const id = 'tests.legal' +const connector = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp' } +const tool = JSON.stringify(['mcp', 'legal-search', 'legal__search']) +const subagent = { id: 'law-finder', kind: 'npc', name: 'Law finder', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [tool] } } + +function manifest(contributes: Record, patch: Record = {}) { + return { schemaVersion: 1, id, name: 'Legal', version: '1.0.0', apiVersion: 4, engines: { lexora: '*' }, contributes, ...patch } +} + +describe('manifest capabilities', () => { + it('parses MCP connectors and NPC sub-agents with their defaults', () => { + const parsed = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector], subagents: [subagent] })) + expect(parsed.contributes.mcpConnectors).toEqual([{ ...connector, deferred: true, credential: 'none' }]) + expect(parsed.contributes.subagents[0]).toMatchObject({ kind: 'npc', description: '', fileAccess: 'read_only', tools: { mode: 'selected' } }) + expect(parsed.entry).toBeUndefined() + expect(extensionManifestSchema.parse(manifest({})).contributes).toMatchObject({ mcpConnectors: [], subagents: [] }) + }) + + it('needs API 4 and a package that declares every connector its sub-agents select', () => { + expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [connector] }, { apiVersion: 3 })).success).toBe(false) + expect(extensionManifestSchema.safeParse(manifest({ subagents: [{ ...subagent, tools: { mode: 'all' } }] }, { apiVersion: 3 })).success).toBe(false) + expect(extensionManifestSchema.safeParse(manifest({ subagents: [subagent] })).success).toBe(false) + expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [{ ...connector, id: 'other' }], subagents: [subagent] })).success).toBe(false) + expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [connector], subagents: [subagent] })).success).toBe(true) + expect(extensionManifestSchema.safeParse(manifest({ subagents: [{ ...subagent, tools: { mode: 'selected', ids: [JSON.stringify(['builtin', 'core', 'read'])] } }] })).success).toBe(true) + }) + + it.each([ + ['an actor sub-agent', { subagents: [{ ...subagent, kind: 'actor', tools: { mode: 'none' } }] }], + ['a plain HTTP address', { mcpConnectors: [{ ...connector, url: 'http://legal.example.com/mcp' }] }], + ['an address with credentials', { mcpConnectors: [{ ...connector, url: 'https://user:secret@legal.example.com/mcp' }] }], + ['a local command transport', { mcpConnectors: [{ id: 'local', name: 'Local', transport: 'stdio', command: 'npx', args: [] }] }], + ['a duplicate connector id', { mcpConnectors: [connector, connector] }], + ['a built-in sub-agent id', { subagents: [{ ...subagent, id: 'npc-explorer', tools: { mode: 'none' } }] }], + ['empty instructions', { subagents: [{ ...subagent, instructions: '', tools: { mode: 'none' } }] }], + ['an unknown field', { mcpConnectors: [{ ...connector, headers: { Authorization: 'secret' } }] }], + ])('rejects %s', (_name, contributes) => { + expect(extensionManifestSchema.safeParse(manifest(contributes)).success).toBe(false) + }) + + it('names the connectors a sub-agent selects tools from', () => { + const base = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector], subagents: [subagent] })).contributes.subagents[0]! + expect(subagentConnectorReferences(base)).toEqual(['legal-search']) + expect(subagentConnectorReferences({ tools: { mode: 'all' } })).toEqual([]) + expect(extensionSubagentId(id, 'law-finder')).toBe('ext.tests.legal.law-finder') + }) + + it('exchanges descriptors strictly', () => { + const descriptor = { extensionId: id, extensionName: 'Legal', revision: 'a', enabled: true, mcpConnectors: [{ ...connector, deferred: true, credential: 'none' }], subagents: [] } + expect(extensionCapabilityDescriptorSchema.parse(descriptor)).toEqual(descriptor) + expect(extensionCapabilityDescriptorSchema.safeParse({ ...descriptor, extra: true }).success).toBe(false) + }) +}) diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 75e9805dd..3417b6ea2 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -4,6 +4,7 @@ import type { WorkbenchHitRegion, WorkbenchPaneSnapshot } from '../workbench/wor import type { JsonValue } from '../workbench/workbenchState' import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi' import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from './extensionActionApi' +import type { ExtensionCapabilityImpact } from './extensionCapabilities' import type { ExtensionCatalogSnapshot } from './extensionCatalog' import type { ExtensionCompatibilityIssue, ExtensionIncompatibleReview } from './extensionCompatibility' import type { ExtensionConditionsChanged, ExtensionConditionState } from './extensionConditions' @@ -168,6 +169,7 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('restart'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('applyUpdate'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('uninstall'), id: extensionIdSchema, clearData: z.boolean().default(false) }).strict(), + z.object({ action: z.literal('capabilityImpact'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(), @@ -209,6 +211,7 @@ export interface ExtensionApi { restart: (id: string) => Promise applyUpdate: (id: string) => Promise uninstall: (id: string, options?: { clearData?: boolean }) => Promise + capabilityImpact: (id: string) => Promise devtools: (id: string) => Promise revokeResources: (id: string) => Promise execute: (id: string, command: string, resource: import('../spaces/spaceFileApi').SpaceFileTarget | null) => Promise diff --git a/apps/buddy/shared/extensions/extensionCapabilities.ts b/apps/buddy/shared/extensions/extensionCapabilities.ts new file mode 100644 index 000000000..d2be35cfe --- /dev/null +++ b/apps/buddy/shared/extensions/extensionCapabilities.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import { buddyToolSelectionSchema } from '../buddies/buddySettings.ts' + +// Capabilities an extension contributes besides skills (which keep using `contributes.skills`): MCP connector +// records and NPC sub-agents. Neither carries code. The host turns them into rows in its own stores. +const localId = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/) + +export const extensionMcpConnectorContributionSchema = z.object({ + id: localId, + name: z.string().trim().min(1).max(128), + transport: z.literal('streamable-http'), + url: z.url().max(2048).refine((value) => { + const url = new URL(value) + return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password + }, 'Expected an HTTPS address without credentials'), + deferred: z.boolean().default(true), + credential: z.enum(['none', 'bearer', 'oauth']).default('none'), +}).strict() + +export const extensionSubagentContributionSchema = z.object({ + id: localId, + kind: z.literal('npc'), + name: z.string().trim().min(1).max(80), + description: z.string().trim().max(500).default(''), + instructions: z.string().min(1).max(16000), + tools: buddyToolSelectionSchema, + fileAccess: z.enum(['inherit', 'read_only']).default('read_only'), +}).strict() + +export type ExtensionMcpConnectorContribution = z.infer +export type ExtensionSubagentContribution = z.infer + +/** MCP connectors a sub-agent's selected tools point at, from the `[source, group, name]` tool identities. */ +export function subagentConnectorReferences(subagent: Pick): string[] { + if (subagent.tools.mode !== 'selected') + return [] + const ids = subagent.tools.ids.flatMap((id) => { + const identity: unknown = JSON.parse(id) + return Array.isArray(identity) && identity[0] === 'mcp' && typeof identity[1] === 'string' ? [identity[1]] : [] + }) + return [...new Set(ids)] +} + +export const extensionCapabilityDescriptorSchema = z.object({ + extensionId: z.string().min(1), + extensionName: z.string().min(1), + revision: z.string().min(1), + enabled: z.boolean(), + mcpConnectors: z.array(extensionMcpConnectorContributionSchema), + subagents: z.array(extensionSubagentContributionSchema), +}).strict() + +export type ExtensionCapabilityDescriptor = z.infer + +export const extensionCapabilityImpactSchema = z.object({ + modified: z.array(z.object({ kind: z.enum(['mcp', 'agent']), name: z.string() }).strict()), +}).strict() +export type ExtensionCapabilityImpact = z.infer + +export const extensionCapabilityImpactRpc = { + method: 'extensions.capabilities.impact', + input: z.object({ extensionId: z.string().min(1).max(120) }).strict(), + response: extensionCapabilityImpactSchema, +} as const + +export const extensionCapabilitiesRpc = { + list: 'extensions.capabilities.list', + changed: 'extensions.capabilities.changed', + impact: extensionCapabilityImpactRpc.method, +} as const + +/** Row id of a sub-agent an extension contributes. The prefix keeps it apart from sub-agents the user created and from the built-in ones. */ +export function extensionSubagentId(extensionId: string, subagentId: string): string { + return `ext.${extensionId}.${subagentId}` +} + +/** Sub-agents that ship inside the application. A package may not declare one with the same ID. */ +export const BUILTIN_SUBAGENT_IDS = ['npc-explorer', 'npc-researcher', 'npc-executor', 'npc-reviewer'] as const + +export function connectorToolExposure(connector: Pick) { + return connector.deferred ? 'deferred' as const : 'direct' as const +} diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index ec9e85424..d5b8dc7eb 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -7,6 +7,7 @@ import { workbenchSlashSchema } from '../workbench/workbenchCommand.ts' import { workbenchConditionSchema } from '../workbench/workbenchContext.ts' import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi.ts' import { extensionAgentSchema } from './extensionAgent.ts' +import { BUILTIN_SUBAGENT_IDS, extensionMcpConnectorContributionSchema, extensionSubagentContributionSchema, subagentConnectorReferences } from './extensionCapabilities.ts' import { extensionConditionDefinitionSchema } from './extensionConditions.ts' import { extensionAuthorSchema } from './extensionIdentity.ts' import { extensionPathSchema } from './extensionPath.ts' @@ -130,6 +131,8 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ permissions: extensionPermissionsSchema.prefault({}), contributes: z.object({ skills: z.array(extensionSkillContributionSchema).max(32).default([]), + mcpConnectors: z.array(extensionMcpConnectorContributionSchema).max(16).default([]), + subagents: z.array(extensionSubagentContributionSchema).max(16).default([]), iconThemes: z.array(fileIconThemeContributionSchema).max(32).default([]), themes: z.array(themeContributionSchema).max(64).default([]), conditions: z.array(extensionConditionDefinitionSchema).max(32).default([]), @@ -167,6 +170,16 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({ const skillRoots = extensionSkillRoots(manifest.contributes.skills) if (skillRoots.length && manifest.apiVersion < 4) issue('Skill contributions require API 4') + const { mcpConnectors, subagents } = manifest.contributes + if ((mcpConnectors.length || subagents.length) && manifest.apiVersion < 4) + issue('MCP connector and sub-agent contributions require API 4') + if (new Set(mcpConnectors.map(connector => connector.id)).size !== mcpConnectors.length || new Set(subagents.map(subagent => subagent.id)).size !== subagents.length) + issue('MCP connector and sub-agent IDs must be unique') + if (subagents.some(subagent => (BUILTIN_SUBAGENT_IDS as readonly string[]).includes(subagent.id))) + issue('Sub-agent IDs must not reuse built-in ones') + // A sub-agent may only select tools of MCP connectors that travel with the same package, so the selection never points at a missing tool. + if (subagents.some(subagent => subagentConnectorReferences(subagent).some(id => !mcpConnectors.some(connector => connector.id === id)))) + issue('Sub-agent tools may only reference MCP connectors declared by the same extension') if (skillRoots.some((root, index) => skillRoots.some((other, otherIndex) => index !== otherIndex && root.startsWith(other)))) issue('Skill directories must be distinct and must not contain one another') if ([manifest.entry, ...manifest.contributes.views.map(view => view.entry)].some(entry => entry && skillRoots.some(root => entry.startsWith(root)))) diff --git a/apps/buddy/src/modules/extensions/extensionLabels.ts b/apps/buddy/src/modules/extensions/extensionLabels.ts index 30b0dd3f5..392804e84 100644 --- a/apps/buddy/src/modules/extensions/extensionLabels.ts +++ b/apps/buddy/src/modules/extensions/extensionLabels.ts @@ -54,6 +54,18 @@ const zh = { noAdded: '没有新增权限', installFailed: '操作未完成', error: '错误代码', + reviewMcp: 'MCP 连接', + reviewMcpNoDownload: '每个连接器只添加一条记录,不下载任何代码;默认关闭,由你在设置里启用。', + reviewMcpAddress: '连接地址', + reviewMcpCredential: { none: '无需凭据,可直接启用', bearer: '需要 Token:安装后保持关闭,在设置里填写 Token 后再启用', oauth: '需要登录授权:安装后在设置里登录' }, + reviewMcpExposure: { deferred: '工具按需发现', direct: '工具直接提供给模型' }, + reviewAgents: '子代理', + reviewAgentDelegate: '安装后自动加入默认伙伴的可委派列表;卸载时一并移除。', + reviewAgentTools: { all: '可使用全部工具', none: '不使用工具', selected: '仅限 {n} 个指定工具' }, + reviewAgentFiles: { inherit: '文件权限与当前任务一致', read_only: '只能读取文件,不能修改' }, + reviewAgentConnectors: '使用的 MCP(随本包一起安装):', + keptTitle: '你修改过的内容会保留,不会被删除:', + keptKinds: { mcp: 'MCP 连接', agent: '子代理' }, } const en: typeof zh = { title: 'Plugins', @@ -111,6 +123,18 @@ const en: typeof zh = { noAdded: 'No additional permissions', installFailed: 'The operation did not complete', error: 'Error code', + reviewMcp: 'MCP connections', + reviewMcpNoDownload: 'Adds one connector record and downloads no code. It stays off until you enable it in settings.', + reviewMcpAddress: 'Address', + reviewMcpCredential: { none: 'No credential needed; can be enabled right away', bearer: 'Needs a token: stays off until you enter it in settings and enable it', oauth: 'Needs sign-in: authorize it in settings after installing' }, + reviewMcpExposure: { deferred: 'Tools are discovered on demand', direct: 'Tools are offered to the model directly' }, + reviewAgents: 'Sub-agents', + reviewAgentDelegate: 'Added to the default companion’s delegation list on install and removed again on uninstall.', + reviewAgentTools: { all: 'May use every tool', none: 'Uses no tools', selected: 'Limited to {n} selected tools' }, + reviewAgentFiles: { inherit: 'File access follows the current task', read_only: 'Can read files but not change them' }, + reviewAgentConnectors: 'MCP connections it uses (installed with this package):', + keptTitle: 'Content you changed is kept and will not be deleted:', + keptKinds: { mcp: 'MCP connection', agent: 'Sub-agent' }, } export function extensionLabels(language: string) { return language === 'en-US' ? en : zh diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue index 7b0b2a354..96aa7b008 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue @@ -1,6 +1,7 @@