diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts index ee017bec3..3de2b84f4 100644 --- a/apps/buddy/electron.vite.config.ts +++ b/apps/buddy/electron.vite.config.ts @@ -48,7 +48,6 @@ export default defineConfig({ }, output: { format: 'es', - banner: chunk => chunk.name === 'extension-compiler' ? 'import { fileURLToPath as compilerFilePath } from "node:url"; import { dirname as compilerDirname } from "node:path"; const __filename = compilerFilePath(import.meta.url); const __dirname = compilerDirname(__filename);' : '', }, }, }, diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index db40f27a9..13459fcc8 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, extensionCapabilityApplyRpc, extensionCapabilityImpactSchema } from '../../../shared/extensions/extensionCapabilities' import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext' import { extensionShellRpc } from '../../../shared/extensions/extensionShell' import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills' @@ -160,6 +161,11 @@ 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 })), + syncCapabilities: async (descriptors, clearExtensionId) => { + extensionCapabilityApplyRpc.response.parse(await service.request(extensionCapabilitiesRpc.apply, { descriptors, clearExtensionId })) + }, 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) => { @@ -191,14 +197,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/SandboxedExtensionHost.ts b/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts index 73ffbe28b..3be41a5de 100644 --- a/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts +++ b/apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts @@ -21,12 +21,13 @@ export class SandboxedExtensionHost implements ExtensionHost { readonly #pending = new Map() readonly #ready: Promise readonly #broker: (method: string, params: unknown) => Promise - readonly #cleanup: () => void + readonly #cleanup: () => Promise readonly #onFailure: (failure: ExtensionHostFailure) => void #resolveReady!: () => void #rejectReady!: (error: Error) => void #io = 0 #closing = false + #disposal: Promise | undefined #started = false #developerWindow: BrowserWindow | null = null #readyTimer: ReturnType @@ -51,11 +52,23 @@ export class SandboxedExtensionHost implements ExtensionHost { }) void this.#ready.catch(() => {}) this.#readyTimer = setTimeout(() => this.#fail({ code: 'EXTENSION_REQUEST_TIMEOUT', method: 'ready', stage: 'timeout' }), 15000) - this.#cleanup = () => { - endpoint.dispose() - stopProtocol() - isolated.webRequest.onBeforeRequest(null) - void isolated.closeAllConnections() + const cleanup = [ + () => endpoint.dispose(), + stopProtocol, + () => isolated.closeAllConnections(), + () => { + if (!this.window.isDestroyed()) + this.window.destroy() + if (!this.window.isDestroyed()) + throw new Error('EXTENSION_HOST_STOP_FAILED') + }, + () => isolated.webRequest.onBeforeRequest(null), + ] + this.#cleanup = async () => { + while (cleanup.length) { + await cleanup[0]!() + cleanup.shift() + } } const contents = this.window.webContents contents.setWindowOpenHandler(() => ({ action: 'deny' })) @@ -160,18 +173,22 @@ export class SandboxedExtensionHost implements ExtensionHost { this.window.webContents.openDevTools({ mode: 'detach' }) } - async dispose(): Promise { - if (this.#closing) - return + dispose(): Promise { + if (this.#disposal) + return this.#disposal this.#closing = true this.#rejectAll() - if (this.#started && !this.window.isDestroyed()) - await this.#send('deactivate', null, 1500).catch(() => {}) - this.#rejectAll() - this.#closeDeveloperWindow() - this.#cleanup() - if (!this.window.isDestroyed()) - this.window.destroy() + this.#disposal = Promise.resolve().then(async () => { + if (this.#started && !this.window.isDestroyed()) + await this.#send('deactivate', null, 1500).catch(() => {}) + this.#rejectAll() + this.#closeDeveloperWindow() + await this.#cleanup() + }).catch((error) => { + this.#disposal = undefined + throw error + }) + return this.#disposal } #send(method: string, params: JsonValue, timeout: number, signal?: AbortSignal): Promise { @@ -207,8 +224,16 @@ export class SandboxedExtensionHost implements ExtensionHost { #closeDeveloperWindow(): void { const window = this.#developerWindow this.#developerWindow = null - if (window && !window.isDestroyed()) - window.destroy() + try { + if (window && !window.isDestroyed()) + window.destroy() + if (window && !window.isDestroyed()) + throw new Error('EXTENSION_HOST_STOP_FAILED') + } + catch (error) { + this.#developerWindow = window + throw error + } } #fail(failure: ExtensionHostFailure = { code: 'EXTENSION_HOST_CRASHED', method: 'unknown', stage: 'process' }): void { diff --git a/apps/buddy/electron/main/extensions/__tests__/SandboxedExtensionHost.spec.ts b/apps/buddy/electron/main/extensions/__tests__/SandboxedExtensionHost.spec.ts index c26e1a37c..65bb2791e 100644 --- a/apps/buddy/electron/main/extensions/__tests__/SandboxedExtensionHost.spec.ts +++ b/apps/buddy/electron/main/extensions/__tests__/SandboxedExtensionHost.spec.ts @@ -15,12 +15,25 @@ import { SandboxedExtensionHost } from '../SandboxedExtensionHost' vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') class WebContents extends EventEmitter { + readonly session: unknown + constructor(session: unknown) { + super() + this.session = session + } + mainFrame = {} send(channel: string, message: unknown) { this.emit(channel, message) } setWindowOpenHandler() {} + setDevToolsWebContents() {} + openDevTools() {} } class BrowserWindow extends EventEmitter { - webContents = new WebContents() + readonly webContents: WebContents + constructor(options: { webPreferences: { session: unknown } }) { + super() + this.webContents = new WebContents(options.webPreferences.session) + } + destroyed = false async loadURL() {} isDestroyed() { return this.destroyed } @@ -35,7 +48,7 @@ vi.mock('electron', async () => { fromPartition: () => ({ setPermissionRequestHandler() {}, setPermissionCheckHandler() {}, - webRequest: { onBeforeRequest() {} }, + webRequest: { onBeforeRequest: vi.fn() }, async closeAllConnections() {}, }), }, @@ -55,9 +68,11 @@ async function fixture() { await store.install((await reviewPackage(root, store, value)).token) const failures: ExtensionHostFailure[] = [] const gates = new Map>, done: ReturnType> }>() + const endpoint = { token: 'fixture', url: 'lexora-extension://fixture/__host.html', dispose: vi.fn() } + const stopProtocol = vi.fn() const protocol = { - register: () => ({ token: 'fixture', url: 'lexora-extension://fixture/__host.html', dispose() {} }), - install: () => () => {}, + register: () => endpoint, + install: () => stopProtocol, } as unknown as ExtensionProtocol let host!: SandboxedExtensionHost let commandMessage: ReturnType> | undefined @@ -88,7 +103,7 @@ async function fixture() { return host }, createView: () => { throw new Error('unexpected view') }, - workbench: async () => null, + workbench: async event => event.kind === 'clear-data' ? event.requestId : null, readText: async () => { throw new Error('unexpected external read') }, get: async () => { throw new Error('unexpected network') }, changed() {}, @@ -113,7 +128,7 @@ async function fixture() { return { id: await commandMessage.promise, outcome } } const request = (method: string, params: JsonValue = null) => host.request(method, params) - return { id, root, store, service, host, failures, hold, command, request, resources: createExtensionResourceClient(request), ignoreDeactivate: () => { + return { id, root, store, service, host, failures, endpoint, stopProtocol, hold, command, request, resources: createExtensionResourceClient(request), ignoreDeactivate: () => { acknowledgeDeactivate = false } } } diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index f67d03769..d98693094 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -7,6 +7,7 @@ import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskAction import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' +import type { ExtensionCapabilityDescriptor, 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' @@ -46,11 +47,14 @@ export function registerExtensionIpc(options: { invokeTaskAction: (input: ExtensionTaskActionInput) => Promise shellsChanged?: () => void skillsChanged?: () => void + capabilitiesChanged?: () => void + capabilityImpact?: (extensionId: string) => Promise + syncCapabilities?: (descriptors: ExtensionCapabilityDescriptor[], clearExtensionId?: 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 { @@ -68,6 +72,7 @@ export function registerExtensionIpc(options: { const replies = new Map void }>() const bound = new Set() let resourcePickerOpen = false + let initializing = true const guardNavigation = (event: Electron.Event) => { if (!event.isMainFrame && !protocol.validViewUrl(event.url)) event.preventDefault() @@ -77,6 +82,11 @@ export function registerExtensionIpc(options: { clipboard: createExtensionClipboard(options.getWindow), shellsChanged: options.shellsChanged, skillsChanged: options.skillsChanged, + capabilitiesChanged: options.capabilitiesChanged, + syncCapabilities: async (descriptors, clearExtensionId) => { + if (!initializing) + await options.syncCapabilities?.(descriptors, clearExtensionId) + }, themes: options.themes, createHost: (pkg, broker, failed) => { const host = new SandboxedExtensionHost(pkg, protocol, broker, failed) @@ -238,6 +248,8 @@ export function registerExtensionIpc(options: { else await service.restart(review.manifest.id) } + initializing = false + options.capabilitiesChanged?.() }) const record = safeDiagnosticReporter(options.record) void prepared.catch(error => record({ component: 'desktop.extensions', level: 'warn', event: 'extensions.initialize.failed', errorCode: extensionError(error) })) @@ -285,6 +297,11 @@ 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': { + if (!options.capabilityImpact) + throw new Error('EXTENSION_SERVICE_UNAVAILABLE') + return await options.capabilityImpact(input.id) + } 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) @@ -362,6 +379,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/main/local-chat/buddies.ts b/apps/buddy/electron/main/local-chat/buddies.ts index 200fc4c89..ec4b087e6 100644 --- a/apps/buddy/electron/main/local-chat/buddies.ts +++ b/apps/buddy/electron/main/local-chat/buddies.ts @@ -7,6 +7,7 @@ export function registerBuddiesIpc({ handle, request }: LocalChatIpcContext): vo handle(LOCAL_CHAT_IPC_CHANNELS.buddiesList, () => request(buddiesRpc.list, {})) handle(LOCAL_CHAT_IPC_CHANNELS.buddiesGet, (_event, input) => request(buddiesRpc.get, buddiesRpc.get.input.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.buddiesCreate, (_event, input) => request(buddiesRpc.create, buddiesRpc.create.input.parse(input))) + handle(LOCAL_CHAT_IPC_CHANNELS.buddiesDuplicate, (_event, input) => request(buddiesRpc.duplicate, buddiesRpc.duplicate.input.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.buddiesDelete, (_event, input) => request(buddiesRpc.delete, buddiesRpc.delete.input.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.buddiesResetProfile, (_event, input) => request(buddiesRpc.resetProfile, buddiesRpc.resetProfile.input.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.buddiesSetDefault, (_event, input) => request(buddiesRpc.setDefault, buddiesRpc.setDefault.input.parse(input))) diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index a48186fb1..4bf263850 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/electron/preload/local-chat/buddies.ts b/apps/buddy/electron/preload/local-chat/buddies.ts index fcff491a6..730463106 100644 --- a/apps/buddy/electron/preload/local-chat/buddies.ts +++ b/apps/buddy/electron/preload/local-chat/buddies.ts @@ -10,6 +10,7 @@ export function createBuddiesApi(): Pick { list: () => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesList), get: buddyId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesGet, { buddyId }), create: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesCreate, input), + duplicate: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesDuplicate, input), delete: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesDelete, input), resetProfile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesResetProfile, input), setDefault: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesSetDefault, input), diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index 62de4f6dd..cd2c22297 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -63,6 +63,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { buddiesToolOptions: 'lexora:local-chat:buddies:tool-options', buddiesGet: 'lexora:local-chat:buddies:get', buddiesCreate: 'lexora:local-chat:buddies:create', + buddiesDuplicate: 'lexora:local-chat:buddies:duplicate', buddiesDelete: 'lexora:local-chat:buddies:delete', buddiesResetProfile: 'lexora:local-chat:buddies:resetProfile', buddiesSetDefault: 'lexora:local-chat:buddies:setDefault', @@ -273,6 +274,7 @@ export interface LocalChatApi { list: () => Promise get: (buddyId: string) => Promise create: (input: BuddyCreate) => Promise + duplicate: (input: BuddyMutation) => Promise delete: (input: BuddyMutation) => Promise resetProfile: (input: BuddyMutation) => Promise setDefault: (input: BuddyDefaultUpdate) => Promise diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 74380cdc6..f108b413b 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, ExtensionWorkbenchResult } 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' @@ -93,6 +94,8 @@ export interface ExtensionServicePorts { agentChanged?: () => void shellsChanged?: () => void skillsChanged?: () => void + capabilitiesChanged?: () => void + syncCapabilities?: (descriptors: ExtensionCapabilityDescriptor[], clearExtensionId?: string) => Promise 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 @@ -141,6 +144,7 @@ export class ExtensionService { readonly #documents?: ExtensionDocuments readonly #ports: ExtensionServicePorts readonly #running = new Map() + readonly #pendingStops = new Map() readonly #viewOpenings = new Map() readonly #views = new Map() readonly #diagnostics = new Map>() @@ -191,8 +195,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') { @@ -405,6 +411,30 @@ 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 this.#capabilityDescriptors() + } + + #capabilityDescriptors(): ExtensionCapabilityDescriptor[] { + 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), skills: manifest.contributes.skills.map(skill => skill.id) }] + }) + } + async #readAgentContributions(): Promise { if (this.#disposed) return [] @@ -748,6 +778,7 @@ export class ExtensionService { const id = await this.store.install(token, signal, choices) const installed = this.store.installed[id]! this.#publish({ kind: 'package', action: 'installed', extensionId: id, packageRevision: installed.current.revision, ...(installed.pending ? { pendingRevision: installed.pending.revision } : {}) }) + await this.#ports.syncCapabilities?.(this.#capabilityDescriptors()) this.#log(id, 'installed') }) this.installations.log(job, 'completed', 'installed') @@ -768,8 +799,10 @@ export class ExtensionService { await this.store.enable(id, enabled) if (previous !== enabled) this.#publish({ kind: 'enabled', extensionId: id, enabled }) - if (!enabled) - await this.#stopClosure(id) + if (enabled) + await this.#ports.syncCapabilities?.(this.#capabilityDescriptors()) + else + await this.#stopAndSyncCapabilities(id) this.#clearError(id) await this.scheduler.resume() this.#log(id, enabled ? 'enabled' : 'disabled') @@ -799,6 +832,7 @@ export class ExtensionService { throw new ExtensionCompatibilityError(installed.pending.manifest, incompatibility) await this.#stopClosure(id) await this.store.promote(id) + await this.#ports.syncCapabilities?.(this.#capabilityDescriptors()) this.#publish({ kind: 'package', action: 'promoted', extensionId: id, packageRevision: this.store.installed[id]!.current.revision }) this.#clearError(id) await this.scheduler.resume() @@ -825,7 +859,7 @@ export class ExtensionService { await this.store.enable(id, false) if (record.enabled) this.#publish({ kind: 'enabled', extensionId: id, enabled: false }) - await this.#stopClosure(id) + await this.#stopAndSyncCapabilities(id, clearData) await this.store.resources.revoke(id) await this.store.revokeFileOpeningGrants(id) this.#publish({ kind: 'resources-revoked', extensionId: id }) @@ -839,6 +873,7 @@ export class ExtensionService { } await this.store.removePackages(id) await this.store.uninstall(id) + await this.#ports.syncCapabilities?.(this.#capabilityDescriptors()) this.#diagnostics.delete(id) this.#configurationApplications.delete(id) this.#publish({ kind: 'package', action: 'uninstalled', extensionId: id }) @@ -1529,7 +1564,7 @@ export class ExtensionService { async resetHosts(): Promise { this.#epoch++ - await Promise.all([...this.#running.keys()].map(id => this.#stop(id))) + await Promise.all([...new Set([...this.#running.keys(), ...this.#pendingStops.keys()])].map(id => this.#stop(id))) } async withStoppedStorage(operation: () => Promise): Promise { @@ -1574,7 +1609,7 @@ export class ExtensionService { if (this.#disposed || this.#maintenance || epoch !== this.#epoch) throw new Error('EXTENSION_HOST_STOPPED') for (const dependency of this.#order(id)) { - if (this.#diagnostics.get(dependency)?.error) + if (this.#pendingStops.has(dependency) || this.#diagnostics.get(dependency)?.error) throw new Error('EXTENSION_RESTART_REQUIRED') let running = this.#running.get(dependency) if (!running) { @@ -2186,9 +2221,10 @@ export class ExtensionService { async #stopHost(id: string): Promise { this.conditions.invalidate({ extensionId: id }) - const running = this.#running.get(id) + const running = this.#pendingStops.get(id) ?? this.#running.get(id) if (!running) return + this.#pendingStops.set(id, running) this.#running.delete(id) running.resourceEndpoint?.dispose() this.#ports.themes?.releaseOwner(`${id}:${running.generation}`) @@ -2213,6 +2249,7 @@ export class ExtensionService { this.scheduler.refresh() try { await running.host.dispose() + this.#pendingStops.delete(id) this.#publish({ kind: 'host', extensionId: id, generation: running.generation, status: 'stopped' }) } catch (error) { @@ -2221,19 +2258,31 @@ export class ExtensionService { } } + async #stopAndSyncCapabilities(id: string, clearData = false): Promise { + try { + await this.#stopClosure(id) + } + finally { + await this.#ports.syncCapabilities?.(this.#capabilityDescriptors(), clearData ? id : undefined) + } + } + async #stopClosure(id: string): Promise { const affected = new Set([id]) let changed = true while (changed) { changed = false - for (const [next, running] of this.#running) { + for (const [next, running] of new Map([...this.#running, ...this.#pendingStops])) { if (!affected.has(next) && Object.keys(running.package.manifest.dependencies).some(dependency => affected.has(dependency))) { affected.add(next) changed = true } } } - await Promise.all([...affected].reverse().map(next => this.#stop(next))) + const stopped = await Promise.allSettled([...affected].reverse().map(next => this.#stop(next))) + const failed = stopped.find(result => result.status === 'rejected') + if (failed?.status === 'rejected') + throw failed.reason } #requestFailed(id: string, method: string, error: unknown): void { diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 2767d2922..4e65f63eb 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -1,6 +1,8 @@ import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic' import type { ExtensionHostFailure } from '../../../shared/extensions/extensionDiagnostic' +import type { ExtensionMcpConnectorContribution } from '../../../shared/extensions/extensionMcpConnectors' import type { ExtensionLocalFile } from '../../../shared/extensions/extensionResources' +import type { ExtensionSubagentContribution } from '../../../shared/extensions/extensionSubagents' import type { JsonValue } from '../../../shared/workbench/workbenchState' import type { ExtensionServicePorts } from '../ExtensionService' import { Buffer } from 'node:buffer' @@ -837,3 +839,152 @@ 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') }) + +it.each([false, true])('waits for capability revocation before removing packages and data with clearData=%s', async (clearData) => { + const entered = deferred() + const acknowledged = deferred() + const { service, store } = await fixture({ syncCapabilities: async (snapshot) => { + expect(snapshot.every(descriptor => !descriptor.enabled)).toBe(true) + entered.resolve() + await acknowledged.promise + } }, manifest({ id: 'tests.managed', apiVersion: 4, entry: undefined, contributes: { mcpConnectors: [{ id: 'search', name: 'Search', transport: 'streamable-http', url: 'https://example.com/mcp', authentication: 'none' }] } })) + const pkg = store.installed['tests.managed']!.current + await store.saveData('tests.managed', { retained: true }, 1) + let completed = false + const operation = service.uninstall('tests.managed', clearData).then(() => { + completed = true + }) + await entered.promise + expect(completed).toBe(false) + expect(store.installed['tests.managed']).toBeDefined() + expect(await store.asset(pkg, 'extension.json')).toBeInstanceOf(Uint8Array) + expect(await store.data('tests.managed')).toEqual({ version: 1, value: { retained: true } }) + acknowledged.resolve() + await operation + expect(completed).toBe(true) + expect(store.installed['tests.managed']).toBeUndefined() + await expect(readFile(join(store.packageRoot(pkg), 'extension.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await store.data('tests.managed')).toEqual(clearData ? { version: 0, value: {} } : { version: 1, value: { retained: true } }) +}) + +it.each(['disable', 'uninstall', 'clear'] as const)('stops hosts and cancels dependency work when capability sync fails during %s, retaining a retryable installation', async (operation) => { + const readingStarted = deferred() + const signals: AbortSignal[] = [] + let waiting = false + let failSync = false + const value = manifest({ apiVersion: 4, contributes: { ...manifest().contributes, subagents: [{ id: 'helper', name: 'Helper', instructions: 'Help.', tools: [], fileAccess: 'read_only' }] } }) + const { service, store, root, hosts } = await fixture({ + readText: async (_target, signal) => { + if (!waiting) + return 'Selected' + signals.push(signal) + if (signals.length === 2) + readingStarted.resolve() + return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })) + }, + syncCapabilities: async () => { + if (failSync) + throw new Error('EXTENSION_CAPABILITY_FAILED') + }, + }, value) + await store.install((await reviewPackage(root, store, manifest({ id: 'tests.dependent', dependencies: { [value.id]: '^1.0.0' } }))).token) + await store.saveData(value.id, { retained: true }, 1) + await store.saveConfiguration(value.id, { enabled: false }) + const pkg = store.installed[value.id]!.current + await service.execute(value.id, `${value.id}.open`, target) + await service.execute('tests.dependent', 'tests.dependent.open', target) + waiting = true + const readings = Promise.allSettled(hosts.map(host => host.broker('resources.readText', { id: (host.commands[0] as { resource: { id: string } }).resource.id }))) + await readingStarted.promise + failSync = true + const change = () => operation === 'disable' ? service.enable(value.id, false) : service.uninstall(value.id, operation === 'clear') + await expect(change()).rejects.toThrow('EXTENSION_CAPABILITY_FAILED') + expect(hosts).toHaveLength(2) + expect(hosts.every(host => host.disposed)).toBe(true) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(await readings).toEqual(signals.map(() => ({ status: 'rejected', reason: expect.objectContaining({ name: 'AbortError' }) }))) + for (const host of hosts) + await expect(host.broker('storage.set', { value: 'late', version: 1 })).rejects.toThrow('EXTENSION_HOST_STOPPED') + expect((await service.list()).find(item => item.manifest.id === value.id)).toMatchObject({ enabled: false, state: 'disabled', generation: null }) + expect(JSON.parse(await readFile(join(store.root, 'installed.json'), 'utf8')).installed[value.id].enabled).toBe(false) + expect(await store.asset(pkg, 'extension.json')).toBeInstanceOf(Uint8Array) + expect(await store.data(value.id)).toEqual({ version: 1, value: { retained: true } }) + expect(JSON.parse(await readFile(join(store.root, 'data', value.id, 'configuration.json'), 'utf8'))).toEqual({ enabled: false }) + failSync = false + await change() + expect(store.installed[value.id]?.enabled).toBe(operation === 'disable' ? false : undefined) + expect(await store.data(value.id)).toEqual(operation === 'clear' ? { version: 0, value: {} } : { version: 1, value: { retained: true } }) +}) + +it('revokes capabilities despite disposal failure and retries the failed host before deleting its package', async () => { + let failStop = true + let running = false + let revoked = false + const value = manifest({ apiVersion: 4, contributes: { ...manifest().contributes, subagents: [{ id: 'helper', name: 'Helper', instructions: 'Help.', tools: [], fileAccess: 'read_only' }] } }) + const { service, store } = await fixture({ + createHost: () => ({ + call: async (method) => { + if (method === 'activate') + running = true + return null + }, + dispose: async () => { + if (failStop) + throw new Error('EXTENSION_HOST_DISPOSE_FAILED') + running = false + }, + devtools: () => {}, + }), + syncCapabilities: async (descriptors) => { revoked = descriptors.every(descriptor => !descriptor.enabled) }, + }, value) + const pkg = store.installed[value.id]!.current + await store.saveData(value.id, { retained: true }, 1) + await service.execute(value.id, `${value.id}.open`, null) + try { + for (let attempt = 0; attempt < 2; attempt++) { + await expect(service.uninstall(value.id, true)).rejects.toThrow('EXTENSION_HOST_DISPOSE_FAILED') + expect(running).toBe(true) + expect(revoked).toBe(true) + expect(store.installed[value.id]?.enabled).toBe(false) + expect(await store.asset(pkg, 'extension.json')).toBeInstanceOf(Uint8Array) + expect(await store.data(value.id)).toEqual({ version: 1, value: { retained: true } }) + } + failStop = false + await service.uninstall(value.id, true) + expect(running).toBe(false) + expect(store.installed[value.id]).toBeUndefined() + await expect(readFile(join(store.packageRoot(pkg), 'extension.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await store.data(value.id)).toEqual({ version: 0, value: {} }) + } + finally { + failStop = false + } +}) + +const connector: ExtensionMcpConnectorContribution = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', authentication: 'bearer' } +const subagent: ExtensionSubagentContribution = { id: 'law-finder', name: 'Law finder', description: '', instructions: 'Search statutes first.', skills: [], requiredSkills: [], tools: [{ source: 'mcp', connector: 'legal-search', name: 'legal__search' }], fileAccess: 'inherit' } + +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', authentication: 'bearer' }], subagents: [{ id: 'law-finder' }] }) + 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/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts index ecd962775..e134280b1 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionServiceEvents.spec.ts @@ -94,7 +94,7 @@ describe('extension service facts', () => { const input = params as { operationId: string, generation: string, configurationRevision: string } return { operationId: input.operationId, generation: input.generation, configurationRevision: input.configurationRevision, applied: false } }, - dispose: async () => { throw new Error('EXTENSION_HOST_STOP_FAILED') }, + dispose: vi.fn(async () => {}).mockRejectedValueOnce(new Error('EXTENSION_HOST_STOP_FAILED')), devtools() {}, })) await service.execute('tests.events', 'tests.events.open', null) @@ -103,5 +103,8 @@ describe('extension service facts', () => { expect(service.snapshot.extensions[0]).toMatchObject({ generation: null, active: false, configuration: { status: 'invalidated' } }) expect(changes.filter(change => change.kind === 'configuration').map(change => change.application.status)).toEqual(['committed', 'invalidated']) expect(changes.filter(change => change.kind === 'host').at(-1)).toMatchObject({ status: 'stop-failed', errorCode: 'EXTENSION_HOST_STOP_FAILED' }) + await expect(service.execute('tests.events', 'tests.events.open', null)).rejects.toThrow('EXTENSION_RESTART_REQUIRED') + await service.resetHosts() + expect(changes.filter(change => change.kind === 'host').at(-1)).toMatchObject({ status: 'stopped' }) }) }) diff --git a/apps/buddy/platform/extensions/extensionState.ts b/apps/buddy/platform/extensions/extensionState.ts index f58fd55f9..fd500f32a 100644 --- a/apps/buddy/platform/extensions/extensionState.ts +++ b/apps/buddy/platform/extensions/extensionState.ts @@ -12,7 +12,7 @@ export type ReviewedFileOpening = z.infer const recordSchema = z.object({ current: extensionPackageSchema, pending: extensionPackageSchema.nullable(), enabled: z.boolean(), development: z.boolean(), pendingFileOpeners: z.array(reviewedFileOpeningSchema).max(4096).default([]) }).strict() export const extensionIndexSchema = z.object({ version: z.literal(1), installed: z.record(extensionIdSchema, recordSchema), fileOpeningDefaults: fileOpeningDefaultsSchema.default({}), fileOpeningRevisions: fileOpeningRevisionSchema.default({}) }).strict() export const storedPackageSchema = extensionPackageSchema.extend({ manifest: z.unknown() }) -export const storedRecordSchema = recordSchema.extend({ current: storedPackageSchema, pending: storedPackageSchema.nullable() }) +export const storedRecordSchema = recordSchema.extend({ current: storedPackageSchema, pending: storedPackageSchema.nullable(), subagentBindings: z.unknown().optional(), pendingSubagentBindings: z.unknown().optional() }).transform(({ subagentBindings: _subagentBindings, pendingSubagentBindings: _pendingSubagentBindings, ...record }) => record) export const storedIndexSchema = extensionIndexSchema.extend({ installed: z.record(extensionIdSchema, storedRecordSchema) }) export const extensionJobSchema = extensionScheduleSchema.extend({ extensionId: extensionIdSchema }) export const extensionScheduleStateSchema = z.object({ version: z.literal(1), jobs: z.array(extensionJobSchema).max(512) }).strict() diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 15e783a0b..66a5422bb 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: 创建、修改并验证可安装的 Lexora 桌面插件,包括视图与面板、文件编辑、Agent 工具与任务动作、配套 Skills、主题、工作台装饰、网络查询和定时命令。也响应 $plugin-creator。普通任务执行、使用现有插件、其他产品的插件开发不属于此技能。 +description: 创建、修改并验证可安装的 Lexora 桌面插件,包括视图与面板、文件编辑、Agent 工具与任务动作、Skills、MCP 连接、NPC、主题、工作台装饰、网络查询和定时命令。也响应 $plugin-creator。普通任务执行、使用现有插件、其他产品的插件开发不属于此技能。 --- # 创建 Lexora 插件 @@ -31,9 +31,11 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 | 原生设置、配置热应用、条件函数 | [设置](references/settings.md)、[条件](references/conditions.md) | | 配色、图标、窗口样式和装饰 | [主题](references/themes.md)、[图标](references/file-icon-themes.md)、[样式](references/styles.md) | | Worker/WASM、插件 Skills、解释器候选 | [计算](references/workers.md)、[Skills](references/skills.md)、[Shell](references/shells.md) | +| 随插件提供远端 MCP 连接 | [MCP 连接](references/mcp-connectors.md) | +| 随插件提供专业 NPC | [伙伴:NPC](references/buddies.md) | | 权限或访问失败 | [权限](references/permissions.md) | -纯主题和纯 Skills 包不需要脚本入口。仅声明实际需要的权限,确认能力所属的后台、视图或本次 Agent 调用,不能互换上下文。 +主题、Skills、MCP 连接、NPC 均可独立声明,无需脚本入口;MCP 连接和 NPC 声明不需要额外插件权限,实际使用仍由宿主授权。仅声明实际需要的权限,确认能力所属的后台、视图或本次 Agent 调用,不能互换上下文。 ## 3. 创建源码并构建 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 f98a7cbec..5d2973ad2 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 @@ -50,8 +50,55 @@ export interface NavigationContribution { showHeader?: boolean when?: WorkbenchCondition } -/** API 4: contributes.skills; path points to a packaged Skill directory's SKILL.md. */ +/** API 4: contributes.skills; id is plugin-prefixed and package-wide unique; path points to a packaged Skill directory's SKILL.md. */ export interface SkillContribution { id: string, path: string } +/** + * API 4 increment: contributes.mcpConnectors; see mcp-connectors.md and set engines.lexora to verified supporting hosts. + * Independent declarations need no other contributions, entry or plugin permissions. + * Connection definitions follow the plugin; credentials, enablement and authorization remain host-managed. + */ +export interface McpConnectorContribution { + /** Unprefixed package-local ID matching /^[a-z0-9][a-z0-9_-]{0,63}$/, unique among MCP connectors; not a user connection ID. */ + id: string + name: string + transport: 'streamable-http' + /** HTTPS address without userinfo or secrets. */ + url: string + /** Required authentication requirement, not credentials or a guarantee of host OAuth support. */ + authentication: 'none' | 'bearer' | 'oauth' +} +export type SubagentToolReference = { + source: 'mcp' + /** Local ID declared in this package's contributes.mcpConnectors. */ + connector: string + /** Exact remote tool name, not a host-generated or JSON-encoded identity. */ + name: string +} | { + source: 'builtin' + name: 'read' | 'find' | 'grep' | 'ls' +} +/** + * API 4 increment: contributes.subagents (NPC); see buddies.md and set engines.lexora to verified supporting hosts. + * Independent declarations need no MCP connectors, Skills, entry or plugin permissions and do not bind themselves to a companion. + * Enablement, model selection and companion bindings are user configuration, not manifest fields. + * Customize a copy; explicitly referenced plugin Skills and connectors remain dependencies of that copy. + */ +export interface SubagentContribution { + /** Unprefixed package-local ID matching /^[a-z0-9][a-z0-9_-]{0,63}$/, unique among NPCs; built-in NPC IDs are reserved. */ + id: string + name: string + /** Defaults to ''. */ + description?: string + instructions: string + /** Unique full plugin-prefixed contributes.skills IDs from this package. Defaults to []. */ + skills?: string[] + /** Unique subset of skills that must be available. Defaults to []. */ + requiredSkills?: string[] + /** Unique explicit references; no all-tools selection. Defaults to []. */ + tools?: SubagentToolReference[] + /** Required. Inherits the task's limits or restricts file access; read_only forbids MCP references. */ + 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/buddies.md b/apps/buddy/service/resources/skills/plugin-creator/references/buddies.md new file mode 100644 index 000000000..c46e1660b --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/buddies.md @@ -0,0 +1,53 @@ +# 插件伙伴 + +当前仅支持贡献 NPC,暂不支持通过插件注册主角。 + +## NPC + +API 4 的 `contributes.subagents` 独立声明专业 NPC,不需要 MCP、Skills、`entry` 或额外插件权限。界面名称为 **NPC**,清单键仍为 `subagents`;不要改名或声明 `kind`。安装不会自动把 NPC 绑定到伙伴。 + +通过 `engines.lexora` 限定实际支持此字段的宿主版本,不能仅凭 `apiVersion: 4` 宣称兼容所有 API 4 宿主。以下片段并入[插件清单](protocol.md);保留插件身份,兼容范围填写目标宿主已确认的支持版本,不照搬其他能力的示例版本。 + +```json +{ + "apiVersion": 4, + "contributes": { + "subagents": [ + { + "id": "reader", + "name": "材料阅读员", + "instructions": "阅读用户指定的材料,返回摘要与原文依据。", + "tools": [{ "source": "builtin", "name": "read" }], + "fileAccess": "read_only" + } + ] + } +} +``` + +该例只选择内置读取工具,不获得额外目录权限;也可以省略 `tools`,声明不使用工具的 NPC。 + +### 声明约束 + +- 必填 `id`、`name`、`instructions`、`fileAccess`,最多 16 个 NPC。`id` 为 1–64 个小写字母、数字、`-`、`_`,首字符只能是字母或数字,同包的 NPC 内不得重复,不能使用内置 ID:`npc-explorer`、`npc-researcher`、`npc-executor`、`npc-reviewer`。 +- `id` 是包内局部 ID,**不加插件 ID 前缀**。命令、视图、Skills 等贡献的前缀规则不适用于 NPC;不同贡献种类的同名局部 ID 不表示依赖或关联。 +- `name` 为 1–80 字符,`instructions` 为 1–16000 字符;`description` 默认空字符串,最长 500 字符。`skills`、`requiredSkills`、`tools` 默认空数组;空数组不表示继承全部能力。 +- `tools` 最多 256 项且不得重复。内置工具使用 `{ "source": "builtin", "name": "read" }`,仅允许 `read`、`find`、`grep`、`ls`。不支持全选、JSON 编码工具 ID、任意宿主管理工具、命令执行或插件工具引用。 +- `fileAccess` 必须显式指定:`inherit` 继承当前任务已有的文件权限上限,不增加授权;`read_only` 限制文件访问为只读。含 MCP 工具引用时必须为 `inherit`,因为远端工具的名称或提示词不能保证没有写入副作用。 + +### 可选依赖 + +只有显式引用才产生依赖;同包交付不代表自动组合: + +- 使用 [插件 Skills](skills.md) 时,`skills` 与 `requiredSkills` 各最多 32 项且不重复,引用本包 `contributes.skills[].id` 的**完整贡献 ID**(如 `.research`),不是 Skill frontmatter 名或调用名。`requiredSkills` 必须是 `skills` 的子集;必需 Skill 不可用时 NPC 不就绪。 +- 使用 [MCP 连接](mcp-connectors.md) 时,工具引用为 `{ "source": "mcp", "connector": "legal-search", "name": "search_statutes" }`。`connector` 必须是本包已声明连接的局部 ID,不能引用用户连接或其他包的连接;`name` 为服务真实提供的原始工具名,1–256 字符,不使用宿主生成的调用名。该引用须搭配 `fileAccess: "inherit"`,不能照搬上方只读示例。 + +### 配置、副本与生命周期 + +NPC 的名称、说明、指令和能力声明由插件来源管理,更新跟随来源。用户在宿主中选择启用状态、模型和伙伴绑定;清单不包含这些配置,也不分发模型凭据。未指定 NPC 模型时继承调用者实际使用的模型。可委派范围由伙伴中的 NPC 配置决定,不由插件安装编排;实际执行继续受工具策略、目录权限和用户审批约束。 + +需要修改来源定义时,用户可复制为独立 NPC 后编辑。副本不跟随原定义更新,但不是依赖备份:仍引用的插件 Skills 或 MCP 连接在来源停用、卸载或声明被移除后仍会失效。不会自动复制连接、凭据或 Skill 依赖。 + +停用或卸载插件会使其 NPC 不可用。普通卸载保留用户配置,不自动把来源定义转成自建 NPC;清理配置须由用户显式选择,不能默认删除用户副本。 + +不要输出旧的 `kind: "npc"` 或 `tools: {mode, ids}`。验收应覆盖独立 NPC 的模型与绑定、只读限制、所选依赖缺失、来源启停、复制与卸载;清单解析成功不代表已具备执行条件。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md index 2b1dd9054..c58ee2186 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -33,6 +33,8 @@ | 输入、完成事件或任务菜单触发动作 | `agent.actions` 与精确触发声明 | [动作](agent.md#任务动作)、[事件](events.md) | | 请求已配置模型、读取当前任务 | 本次调用的 `models` / `task`,按权限开放 | [模型与任务](agent.md#模型与任务权限) | | 提供可搜索的工作方法与配套资源 | `contributes.skills` | [Skills](skills.md) | +| 提供由宿主管理认证与启用的远端连接 | `contributes.mcpConnectors`,可独立声明 | [MCP 连接](mcp-connectors.md) | +| 提供可由伙伴调用的专业 NPC | `contributes.subagents`,可独立声明 | [伙伴:NPC](buddies.md) | | 原生设置与实时生效条件 | `settings`、`configuration`、`conditions` | [设置](settings.md)、[条件](conditions.md) | | 关闭页面后仍通知或定时执行 | 后台命令、`notifications`、`schedules` | [命令](commands.md#通知与定时命令) | | 检测用户已有命令解释器 | 声明式 `shells`;运行仍归宿主 | [Shell](shells.md) | diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/mcp-connectors.md b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-connectors.md new file mode 100644 index 000000000..f29ec6c14 --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-connectors.md @@ -0,0 +1,41 @@ +# 随插件提供 MCP 连接 + +API 4 的 `contributes.mcpConnectors` 独立声明远端 MCP 连接,不需要其他贡献、`entry` 或额外插件权限。安装声明不等于连接已启用、认证已完成或远端调用已获授权。 + +通过 `engines.lexora` 限定实际支持此字段的宿主版本,不能仅凭 `apiVersion: 4` 宣称兼容所有 API 4 宿主。以下片段并入[插件清单](protocol.md);保留插件身份,兼容范围填写目标宿主已确认的支持版本,不照搬其他能力的示例版本。 + +```json +{ + "apiVersion": 4, + "contributes": { + "mcpConnectors": [ + { + "id": "legal-search", + "name": "法规检索", + "transport": "streamable-http", + "url": "https://legal.example.com/mcp", + "authentication": "bearer" + } + ] + } +} +``` + +将示例地址替换为实际服务地址;此示例只声明连接及认证需求,不包含凭据。 + +## 声明约束 + +- 必填 `id`、`name`、`transport`、`url`、`authentication`,最多 16 个连接。`id` 为 1–64 个小写字母、数字、`-`、`_`,首字符只能是字母或数字,同包的 MCP 连接内不得重复;`name` 为 1–128 字符。 +- `id` 是包内局部 ID,**不加插件 ID 前缀**,不是用户连接 ID。命令、视图、Skills 等贡献的前缀规则不适用于这里。宿主按插件来源区分连接;作者不拼接宿主内部 ID,也不能接管用户或其他包的连接。 +- 只支持 `transport: "streamable-http"`。URL 最长 2048 字符,必须是 HTTPS 且不含 userinfo;不支持本机命令启动的 `stdio`。不要把 Token、密码、API Key 或其他秘密放在 URL、清单或包内资源中。 +- `authentication` 必须显式提供,为 `none`、`bearer` 或 `oauth`,仅声明认证需求。凭据由用户在宿主中配置,不随插件分发;不允许请求头、凭据、`credential` 或 `deferred` 字段。声明 `oauth` 不代表任意目标宿主都支持完整登录流程,需核对实际支持。 + +## 配置与生命周期 + +连接名称、地址、传输与认证要求由插件来源管理,更新跟随来源。用户在宿主中管理凭据、启用状态和工具发现方式,这些不是清单字段。修改连接定义应更新插件;自建连接属于用户自己的配置,不会被同名插件贡献覆盖。 + +启用仍受来源状态与认证条件限制;安装不会自动连接或授予远端调用权限。远端工具的权限和副作用继续由宿主策略与用户授权约束,不能靠清单或提示词把它们变成只读操作。 + +停用、卸载插件或移除声明会使相应连接不可用。普通卸载保留用户配置,不把来源连接自动转换为用户自建连接;只有用户显式选择清理时才删除相应配置与凭据。 + +验收应覆盖认证配置、连接启停、真实工具发现与调用,以及更新和卸载后的不可用状态;清单解析成功不代表远端服务可用。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 86a612be2..c956c7b2f 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -38,11 +38,17 @@ | `fileHandlers` | API 4;数组,每项必填 `id`、`title`、`extensions`;`preview`、`open` 至少提供一个,各含 `view` 和可选 `requestDefault`(默认 `true`);见 [文件关联](resources.md#文件关联与独立入口) | | `launchers` | API 4;数组,每项必填 `id`、`title`、`view`,提供无需文件的独立入口 | | `skills` | API 4;数组,每项为 `{id,path}`,path 指向包内 `SKILL.md`;可独立提供,无需 entry 或额外权限,见 [插件 Skills](skills.md) | +| `mcpConnectors` | API 4 增量;数组,每项必填 `id`、`name`、`transport`、`url`、`authentication`;可独立提供,无需 entry 或额外插件权限,见 [MCP 连接](mcp-connectors.md) | +| `subagents` | API 4 增量;数组,每项必填 `id`、`name`、`instructions`、`fileAccess`;可独立提供 NPC,无需 entry 或额外插件权限,见 [伙伴:NPC](buddies.md) | | `agent` | API 3;指令、工具、任务动作和可选的 enabledWhen,见 [Agent 工具与动作](agent.md) | | `settings` | API 3;声明 modules/groups/items,由宿主渲染并保存,见 [声明式设置](settings.md) | | `shells` | API 4;固定本机解释器候选与原生设置映射;可选 settingsGroup 将插件自有组渲染为一行一个 Shell 的原生表格,文件选择仅限原生设置,需独立 shells 权限,见 [命令解释器](shells.md) | -插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 +插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`。命令、视图、Skills 等贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀,且全包唯一。 + +`mcpConnectors[].id` 与 `subagents[].id` 是例外:使用不带插件前缀的局部 ID,各自在同类贡献中唯一;格式与保留值见对应专题。两类贡献使用相同局部 ID 不会建立关联。NPC 引用 MCP 时使用连接的局部 ID,引用 Skills 时仍使用完整的插件前缀贡献 ID。 + +声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 ## 包格式与版本 @@ -52,7 +58,7 @@ - `version` 标记插件版本;`apiVersion` 标记协议主版本;`engines.lexora` 标记实际支持的宿主版本范围。API 4 内的增量接口仍可能要求更新的宿主,不能仅凭主版本声明兼容。 - `dataVersion` 对应插件私有数据迁移,视图 `stateVersion` 对应实例状态迁移,详见 [生命周期与状态](lifecycle.md)。升级维持插件和贡献 ID,保留用户数据。 -示例版本仅用于说明清单结构。采用文件编辑、引用捕获等新增能力时,以对应专题和实际验收的宿主为准。 +示例版本仅用于说明清单结构。采用文件编辑、引用捕获、MCP 连接或 NPC 等增量能力时,须按对应专题与目标宿主已确认的支持版本填写 `engines.lexora`,不沿用示例范围推断兼容性。 ## 专题索引 @@ -68,4 +74,6 @@ | 原生设置与生效条件 | [设置](settings.md)、[条件](conditions.md) | | 主题、样式与装饰 | [样式](styles.md)、[主题](themes.md)、[文件图标](file-icon-themes.md) | | Worker、WASM、插件 Skills、命令解释器 | [计算](workers.md)、[Skills](skills.md)、[Shell](shells.md) | +| 随插件提供远端 MCP 连接 | [MCP 连接](mcp-connectors.md) | +| 随插件提供专业 NPC | [伙伴:NPC](buddies.md) | | 权限与独立开发 | [权限](permissions.md)、[SDK 工作流](sdk-workflow.md) | diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/skills.md b/apps/buddy/service/resources/skills/plugin-creator/references/skills.md index efb479442..3a5e66b0f 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/skills.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/skills.md @@ -17,7 +17,7 @@ API 4 的 `contributes.skills` 将标准 Skill 随插件分发,可与工具、 } ``` -将 `` 替换为该插件已有的 ID。贡献 ID 必须以插件 ID 加 `.` 开头且全包唯一。最多声明 32 个 Skill;各 Skill 使用独立目录,目录不能相互包含,也不能包含插件宿主或视图的入口文件。 +将 `` 替换为该插件已有的 ID。Skill 贡献 ID 必须以插件 ID 加 `.` 开头且全包唯一;不要套用 MCP 连接或 NPC 的局部 ID 规则。最多声明 32 个 Skill;各 Skill 使用独立目录,目录不能相互包含,也不能包含插件宿主或视图的入口文件。 ```text extension.json diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 817dadbdb..058114286 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, extensionCapabilityApplyRpc, 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' @@ -149,6 +150,7 @@ import { PetActionService } from './pet/PetActionService' import { bindExtensionActions } from './plugins/bindExtensionActions' import { ExtensionActionService } from './plugins/ExtensionActionService' import { ExtensionAgentRuntime } from './plugins/ExtensionAgentRuntime' +import { ExtensionCapabilitySync } from './plugins/ExtensionCapabilitySync' import { createExtensionResourceCapabilities } from './plugins/extensionResourceCapabilities' import { observeExtensionAgentDiagnostics } from './plugins/observeExtensionAgentDiagnostics' import { PluginAuthoringService } from './plugins/PluginAuthoringService' @@ -201,6 +203,7 @@ import { createExtensionInvocationRepository } from './storage/extensionInvocati import { createExtensionReferenceRepository } from './storage/extensionReferenceRepository' import { createExtensionTaskContextRepository } from './storage/extensionTaskContextRepository' import { createMailboxRepository } from './storage/mailboxRepository' +import { createManagedCapabilityRepository } from './storage/managedCapabilityRepository' import { createNotificationAttentionRepository } from './storage/notificationAttentionRepository' import { createProviderRepository } from './storage/providerRepository' import { createRunInputRepository } from './storage/runInputRepository' @@ -343,6 +346,8 @@ export async function startBuddyService( }) return service }) + const managedCapabilities = createManagedCapabilityRepository(options.database) + managedCapabilities.suspendAll() const buddies = await host.start('runtime.buddies', ({ defer }) => { const service = new BuddiesService(createBuddyRepository(options.database), () => record({ event: 'buddies.observer.failed', level: 'warn' })) const notices = service.onDidChange(event => options.rpc.notify(buddiesChanged.method, event)) @@ -689,6 +694,7 @@ export async function startBuddyService( const connectorService = await host.start('runtime.connectors', ({ defer }) => { const service = new McpConnectorService({ connectors: connectorsRepository, + managed: managedCapabilities, openExternal: async (url) => { const result = openExternalResultSchema.parse(await options.rpc.request('host.openExternal', { url })) if (!result.ok) @@ -761,6 +767,38 @@ export async function startBuddyService( connectorService.start() return consumer }) + await host.start('runtime.extension_capabilities', ({ defer }) => { + const sync = new ExtensionCapabilitySync({ + buddies, + connectors: connectorService, + sources: managedCapabilities, + 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)) + const apply = registerRuntimeRequest(options.rpc, extensionCapabilityApplyRpc, async (input, signal) => { + await sync.apply(input.descriptors, input.clearExtensionId, signal) + return null + }) + const refreshAvailability = () => { + void sync.refreshAvailability().catch(() => record({ event: 'extensions.capabilities.availability_failed', level: 'warn', errorCode: 'EXTENSION_CAPABILITY_FAILED' })) + } + const connectorChanges = connectorService.onDidChange(refreshAvailability) + const connectionChanges = connectorService.onDidChangeConnection(refreshAvailability) + defer(options.rpc.onNotification((method) => { + if (method === extensionCapabilitiesRpc.changed) + void sync.schedule() + })) + defer(async () => { + impact() + apply() + connectorChanges.dispose() + connectionChanges.dispose() + await sync.dispose() + }) + 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/agent/execution/ExecutionDelegationService.ts b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts index fefc37532..052aafc7f 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts @@ -13,6 +13,7 @@ import { z } from 'zod' import { currentPlatform } from '../../../../platform/currentPlatform' import { MAX_BUDDY_MESSAGE_TEXT_LENGTH } from '../../../../shared/conversation/buddyMessageContent' import { executionBriefSchema, executionContextModeSchema, executionReviewSchema, executionSubmissionSchema, renderExecutionBrief } from '../../../../shared/executions/executionWork' +import { readAvailableBuddy } from '../../buddies/readAvailableBuddy' import { resolveShellExecution } from '../../sandbox/shellExecution' import { ExecutionAdmissionError, executionFence, hashChildAdmissionRequest, MAX_CHILD_ATTEMPTS_PER_ROOT } from '../../storage/executionRepository' import { getRoleToolDenial } from '../extensions/discovery/SessionToolScope' @@ -265,8 +266,8 @@ export class ExecutionDelegationService { const ids = [...npcIds, ...this.#options.members.list(source.run.conversationId).map(member => member.actorId)] const targets = this.#options.actorTargets?.(source.run.rootRunId) ?? [] const available = [...new Set(ids)].flatMap((id) => { - const buddy = this.#options.buddies.get(id) - return buddy.enabled && !buddy.deletedAt && buddy.id !== source.run.buddyId + const buddy = readAvailableBuddy(id => this.#options.buddies.get(id), id) + return buddy && buddy.id !== source.run.buddyId && (buddy.kind === 'npc' || targets.length < 2 || targets.includes(buddy.id)) ? [{ id: buddy.id, name: buddy.name, description: buddy.description, kind: buddy.kind }] : [] @@ -306,7 +307,8 @@ export class ExecutionDelegationService { shell: unifiedShell || tools.some(tool => tool.name === shell.dialect) ? { tool: unifiedShell ? 'lexora_shell' : shell.dialect, boundary: shell.boundary, readOnly: shell.readOnly } : null, } } - return { self, actors: catalog.actors.map(describe), npcs: catalog.npcs.map(describe) } + const currentCatalog = this.#catalog(source) + return { self, actors: currentCatalog.actors.map(describe), npcs: currentCatalog.npcs.map(describe) } } async #delegate(caller: DelegationCaller, source: ExecutionStateRecord, requestKey: string, input: Exclude, signal: AbortSignal, entries: readonly SessionEntry[]) { diff --git a/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts index e5ab4edc0..10bf432ea 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts @@ -5,6 +5,7 @@ import { SessionManager } from '@earendil-works/pi-coding-agent' import { afterEach, describe, expect, it, vi } from 'vitest' import { buddySettingsSchema } from '../../../../../shared/buddies/buddySettings' import { EXECUTION_MAILBOX_ENTRY_TYPE, executionMailboxMessageSchema } from '../../../../../shared/executions/executionApi' +import { managedBuddyFixture } from '../../../buddies/__tests__/managedBuddyFixture' import { BuddyDefinitionResolver } from '../../../buddies/BuddyDefinitionResolver' import { createExecutionStorageFixture } from '../../../storage/__tests__/executionFixture' import { createConversationMemberRepository } from '../../../storage/conversationMemberRepository' @@ -56,6 +57,24 @@ function fixture() { } describe('execution delegation boundary', () => { + it.each(['disabled', 'removed', 'unavailable', 'dependency'] as const)('hides %s managed NPCs and rejects stale target IDs without blocking actor partners', async (reason) => { + const f = fixture() + const managed = managedBuddyFixture(f.database) + const npc = f.buddies.upsertManagedNpc(managed.input) + const actor = f.buddies.get('default') + f.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds: [npc.id] }) }) + f.runs.create({ ...f.runs.findById('root')!, id: 'managed-root', rootRunId: 'managed-root', actorSnapshot: f.snapshot('default') }) + const caller = { ...f.caller, run: { ...f.caller.run, runId: 'managed-root' } } + await expect(f.service.execute(caller, 'before', { operation: 'list' }, f.controller.signal)).resolves.toMatchObject({ npcs: [{ id: npc.id }] }) + if (reason === 'dependency') + managed.sources.setUnavailableReason(npc.id, 'dependency_unavailable') + else + managed.sources.setState(npc.id, reason) + await expect(f.service.execute(caller, 'after', { operation: 'list' }, f.controller.signal)).resolves.toMatchObject({ actors: [{ id: f.actor.id }], npcs: [] }) + await expect(f.service.execute(caller, 'stale', { operation: 'delegate', targetId: npc.id, text: 'Inspect' }, f.controller.signal)).rejects.toMatchObject({ code: 'unauthorized' }) + expect(f.runs.findById('managed-root')?.actorSnapshot?.settings.npcIds).toEqual([npc.id]) + }) + it('identifies an accepted partner separately from the other delegation targets after profile changes', async () => { const f = fixture() const actor = f.buddies.get(f.actor.id) diff --git a/apps/buddy/service/src/buddies/BuddiesService.ts b/apps/buddy/service/src/buddies/BuddiesService.ts index 4342007bb..e380f9ecf 100644 --- a/apps/buddy/service/src/buddies/BuddiesService.ts +++ b/apps/buddy/service/src/buddies/BuddiesService.ts @@ -55,6 +55,27 @@ 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)) + } + + refreshManaged(id: string): void { + this.#check() + this.#changeProfile(() => this.#repository.refreshManaged(id)) + } + + clearManagedConfiguration(id: string): void { + this.#check() + this.#changeProfile(() => this.#repository.clearManagedConfiguration(id)) + } + + duplicate(input: BuddyMutation, signal?: AbortSignal) { + this.#check(signal) + const request = parse(buddyMutationSchema, input) + return this.#changeProfile(() => this.#repository.duplicate(request)) + } + delete(input: BuddyMutation, signal?: AbortSignal) { this.#check(signal) const request = parse(buddyMutationSchema, input) diff --git a/apps/buddy/service/src/buddies/BuddyDefinitionResolver.ts b/apps/buddy/service/src/buddies/BuddyDefinitionResolver.ts index 891e96407..71754373a 100644 --- a/apps/buddy/service/src/buddies/BuddyDefinitionResolver.ts +++ b/apps/buddy/service/src/buddies/BuddyDefinitionResolver.ts @@ -7,6 +7,7 @@ import { ProviderAuthenticationRequiredError } from '../providers/ProviderFailur import { resolveInteractiveModelSelection } from '../providers/resolveInteractiveModelSelection' import { BuddyServiceError } from '../rpc/runtimeRequest' import { buddyExecutionDefinitionSchema } from './BuddyExecutionDefinition' +import { readAvailableNpcIds } from './readAvailableBuddy' export interface ResolveBuddyDefinitionInput { buddyId: string @@ -72,11 +73,6 @@ export class BuddyDefinitionResolver { throw new BuddyServiceError('BUDDY_DISABLED') if (buddy.kind !== input.kind) throw new BuddyServiceError('BUDDY_NOT_ACTOR') - for (const id of buddy.settings.npcTeamEnabled ? buddy.settings.npcIds : []) { - const target = this.options.buddies.get(id) - if (target.deletedAt || !target.enabled || target.kind !== 'npc') - throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') - } const member = input.kind === 'actor' && input.conversationId ? this.options.members.find(input.conversationId, buddy.id) : null @@ -124,11 +120,13 @@ export class BuddyDefinitionResolver { const current = this.options.buddies.get(definition.id) if (current.revision !== definition.revision) throw new BuddyServiceError('BUDDY_CONFLICT') - for (const id of definition.settings.npcTeamEnabled ? definition.settings.npcIds : []) { - const npc = this.options.buddies.get(id) - if (npc.deletedAt || !npc.enabled || npc.kind !== 'npc') - throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') - } + if (current.deletedAt) + throw new BuddyServiceError('BUDDY_DELETED') + if (!current.enabled) + throw new BuddyServiceError('BUDDY_DISABLED') + definition.settings.npcIds = definition.settings.npcTeamEnabled + ? readAvailableNpcIds(id => this.options.buddies.get(id), definition.settings.npcIds) + : [] if (input.kind === 'actor' && input.conversationId && this.options.members.find(input.conversationId, definition.id) === null) throw new BuddyServiceError('BUDDY_NOT_MEMBER') if (input.kind === 'actor' && input.conversationId && JSON.stringify(this.options.members.find(input.conversationId, definition.id)?.modelSelection ?? null) !== JSON.stringify(memberModelSelection)) diff --git a/apps/buddy/service/src/buddies/__tests__/BuddyDefinitionResolver.spec.ts b/apps/buddy/service/src/buddies/__tests__/BuddyDefinitionResolver.spec.ts index b4b158e49..109db6c62 100644 --- a/apps/buddy/service/src/buddies/__tests__/BuddyDefinitionResolver.spec.ts +++ b/apps/buddy/service/src/buddies/__tests__/BuddyDefinitionResolver.spec.ts @@ -12,6 +12,7 @@ import { openBuddyDatabase } from '../../storage/database' import { BuddiesService } from '../BuddiesService' import { BuddyDefinitionResolver } from '../BuddyDefinitionResolver' import { buddyExecutionDefinitionSchema, buddyExecutionFingerprint } from '../BuddyExecutionDefinition' +import { managedBuddyFixture } from './managedBuddyFixture' const databases: DatabaseSync[] = [] const services: BuddiesService[] = [] @@ -70,10 +71,44 @@ function setup(beforeResolve?: () => Promise) { const current = buddies.get(buddyId) return buddies.update({ buddyId, expectedRevision: current.revision, settings: buddySettingsSchema.parse({ ...current.settings, ...changes }) }) } - return { buddies, conversations, createConversation, repository, resolver, setMemberModel, updateSettings, setDefaultModel: (value: InteractiveModelSelection) => defaultModel = value } + return { database, buddies, conversations, createConversation, repository, resolver, setMemberModel, updateSettings, setDefaultModel: (value: InteractiveModelSelection) => defaultModel = value } } describe('buddy execution definitions', () => { + it.each(['disabled', 'removed', 'unavailable', 'dependency', 'user'] as const)('filters %s managed NPCs from new snapshots without rewriting bindings', async (reason) => { + const f = setup() + const managed = managedBuddyFixture(f.database) + const npc = f.buddies.installManagedNpc(managed.input) + const actor = await f.buddies.create({ name: 'Coordinator' }) + await f.updateSettings(actor.id, { npcIds: [npc.id] }) + const before = await f.resolver.capture({ buddyId: actor.id, kind: 'actor', conversationId: null }) + if (reason === 'dependency') + managed.sources.setUnavailableReason(npc.id, 'dependency_unavailable') + else if (reason === 'user') + managed.sources.setUserEnabled(npc.id, false) + else + managed.sources.setState(npc.id, reason) + f.buddies.refreshManaged(npc.id) + const after = await f.resolver.capture({ buddyId: actor.id, kind: 'actor', conversationId: null }) + expect(after.definition.settings.npcIds).toEqual([]) + expect(before.definition.settings.npcIds).toEqual([npc.id]) + expect(f.buddies.get(actor.id).settings.npcIds).toEqual([npc.id]) + await expect(f.resolver.capture({ buddyId: npc.id, kind: 'npc', conversationId: null, callerModel: modelSelection('caller') })).rejects.toMatchObject({ code: 'BUDDY_DISABLED' }) + }) + + it('rechecks NPC availability after model resolution without blocking the actor', async () => { + let disable = () => {} + const f = setup(async () => disable()) + const managed = managedBuddyFixture(f.database) + const npc = f.buddies.installManagedNpc(managed.input) + const actor = await f.buddies.create({ name: 'Coordinator' }) + await f.updateSettings(actor.id, { npcIds: [npc.id] }) + disable = () => managed.sources.setState(npc.id, 'unavailable') + const resolved = await f.resolver.resolve({ buddyId: actor.id, kind: 'actor', conversationId: null }) + expect(resolved.definition.settings.npcIds).toEqual([]) + expect(f.buddies.get(actor.id).settings.npcIds).toEqual([npc.id]) + }) + it('allows a disabled team to retain unavailable NPC bindings without blocking the actor', async () => { const fixture = setup() const actor = fixture.buddies.get('default') @@ -82,8 +117,11 @@ describe('buddy execution definitions', () => { await fixture.buddies.update({ buddyId: npc.id, expectedRevision: npc.revision, enabled: false }) await fixture.updateSettings(actor.id, { npcTeamEnabled: false }) expect((await fixture.resolver.resolve({ buddyId: actor.id, kind: 'actor', conversationId: null })).definition.settings) - .toEqual({ ...actor.settings, npcTeamEnabled: false }) - await expect(fixture.updateSettings(actor.id, { npcTeamEnabled: true })).rejects.toMatchObject({ code: 'BUDDY_INVALID_NPC_BINDING' }) + .toEqual({ ...actor.settings, npcTeamEnabled: false, npcIds: [] }) + await fixture.updateSettings(actor.id, { npcTeamEnabled: true }) + expect((await fixture.resolver.resolve({ buddyId: actor.id, kind: 'actor', conversationId: null })).definition.settings.npcIds) + .toEqual(actor.settings.npcIds.filter(id => id !== npcId)) + expect(fixture.buddies.get(actor.id).settings.npcIds).toEqual(actor.settings.npcIds) }) it('resolves role defaults before the application default and applies an explicit request without changing either', async () => { @@ -224,7 +262,7 @@ describe('buddy execution definitions', () => { expect(() => resolver.resolveSnapshotIdentity(legacy)).toThrow(failure) }) - it.each(['instructions', 'actor-disabled', 'actor-deleted', 'npc-disabled', 'npc-deleted', 'member-model'] as const)('rejects %s changes while model availability is being validated', async (change) => { + it.each(['instructions', 'actor-disabled', 'actor-deleted', 'npc-disabled', 'npc-deleted', 'member-model'] as const)('rechecks %s changes while model availability is being validated', async (change) => { const started = deferred() const release = deferred() const fixture = setup(async () => { @@ -237,7 +275,9 @@ describe('buddy execution definitions', () => { const conversation = fixture.createConversation('race') fixture.setMemberModel(conversation.id, actor.id, modelSelection('before-validation')) const pending = fixture.resolver.resolve({ buddyId: actor.id, kind: 'actor', conversationId: conversation.id }) - const assertion = expect(pending).rejects.toMatchObject({ code: change.startsWith('npc-') ? 'BUDDY_INVALID_NPC_BINDING' : 'BUDDY_CONFLICT' }) + const assertion = change.startsWith('npc-') + ? expect(pending).resolves.toMatchObject({ definition: { settings: { npcIds: [] } } }) + : expect(pending).rejects.toMatchObject({ code: 'BUDDY_CONFLICT' }) await started.promise if (change === 'member-model') { fixture.setMemberModel(conversation.id, actor.id, modelSelection('after-validation')) diff --git a/apps/buddy/service/src/buddies/__tests__/managedBuddies.spec.ts b/apps/buddy/service/src/buddies/__tests__/managedBuddies.spec.ts new file mode 100644 index 000000000..290b2d7cd --- /dev/null +++ b/apps/buddy/service/src/buddies/__tests__/managedBuddies.spec.ts @@ -0,0 +1,144 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { RuntimeRequestHandler } from '../../../../shared/runtime/rpcPeer' +import { afterEach, describe, expect, it } from 'vitest' +import { buddiesRpc } from '../../../../shared/buddies/buddyApi' +import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings' +import { createBuddyRepository } from '../../storage/buddyRepository' +import { openBuddyDatabase } from '../../storage/database' +import { BuddiesService } from '../BuddiesService' +import { registerBuddiesRpc } from '../registerBuddiesRpc' +import { managedBuddyFixture } from './managedBuddyFixture' + +const cleanups: (() => void)[] = [] +afterEach(() => cleanups.splice(0).reverse().forEach(cleanup => cleanup())) +function fixture() { + const database: DatabaseSync = openBuddyDatabase({ databasePath: ':memory:' }) + const repository = createBuddyRepository(database) + const service = new BuddiesService(repository) + cleanups.push(() => database.close(), () => service.dispose()) + const managed = managedBuddyFixture(database) + const buddy = service.installManagedNpc(managed.input) + return { ...managed, database, repository, service, buddy } +} + +describe('managed NPC definitions', () => { + it('accepts the dedicated duplicate RPC with revision protection and rejects injected identity', async () => { + const f = fixture() + const handlers = new Map() + cleanups.push(registerBuddiesRpc({ onRequest: (method, handler) => { + handlers.set(method, handler) + return () => { + handlers.delete(method) + } + } }, f.service)) + const invoke = async (params: unknown) => handlers.get(buddiesRpc.duplicate.method)!(params) + const request = { buddyId: f.buddy.id, expectedRevision: f.buddy.revision } + const response = buddiesRpc.duplicate.response.parse(await invoke(request)) + expect(response).toMatchObject({ kind: 'npc', settings: f.buddy.settings }) + expect(response.id).not.toBe(f.buddy.id) + expect(response).not.toHaveProperty('managed') + await expect(invoke({ ...request, managed: f.buddy.managed })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + await expect(invoke({ ...request, expectedRevision: request.expectedRevision + 1 })).rejects.toMatchObject({ code: 'BUDDY_CONFLICT' }) + }) + + it('requires a real subagent source and projects source metadata only for managed identities', async () => { + const f = fixture() + expect(() => f.service.installManagedNpc({ ...f.input, id: 'user-chosen-id' })).toThrow(expect.objectContaining({ code: 'BUDDY_INVALID_NPC_BINDING' })) + const connector = f.sources.upsertSource({ extensionId: 'fixture-plugin', extensionName: 'Fixture plugin', contributionId: 'mcp', revision: 'r1', state: 'enabled', kind: 'mcp', definition: { id: 'mcp', name: 'MCP', transport: 'streamable-http', url: 'https://example.test/mcp', authentication: 'none' } }) + expect(() => f.service.installManagedNpc({ ...f.input, id: connector.id })).toThrow(expect.objectContaining({ code: 'BUDDY_INVALID_NPC_BINDING' })) + expect(f.service.get('default')).not.toHaveProperty('managed') + expect(f.service.list().buddies.find(buddy => buddy.id === f.buddy.id)).toMatchObject({ managed: { extensionId: 'fixture-plugin', revision: 'revision-1' } }) + }) + + it('refreshes author fields while retaining the user model, preference and avatar', async () => { + const f = fixture() + const model = { providerId: 'user-provider', modelId: 'user-model', reasoning: null, serviceTier: null } + const edited = await f.service.update({ buddyId: f.buddy.id, expectedRevision: f.buddy.revision, enabled: false, avatar: 'preset:npc-lilac', settings: { ...buddySettingsSchema.parse(f.buddy.settings), model } }) + const updated = f.service.installManagedNpc({ ...f.input, name: 'Updated name', description: 'Updated background', settings: { ...f.input.settings, instructions: 'Updated instructions', tools: { mode: 'none' }, skills: { mode: 'none' }, requiredSkills: [], limits: { fileAccess: 'inherit' } } }) + expect(updated).toMatchObject({ name: 'Updated name', description: 'Updated background', avatar: edited.avatar, enabled: false, managed: { userEnabled: false }, settings: { model, instructions: 'Updated instructions', tools: { mode: 'none' }, skills: { mode: 'none' }, requiredSkills: [], limits: { fileAccess: 'inherit' } } }) + expect(f.service.get(f.buddy.id).revision).toBeGreaterThan(edited.revision) + f.sources.setState(f.buddy.id, 'disabled') + f.service.refreshManaged(f.buddy.id) + f.sources.setState(f.buddy.id, 'enabled') + f.service.refreshManaged(f.buddy.id) + expect(f.service.get(f.buddy.id)).toMatchObject({ enabled: false, managed: { userEnabled: false }, settings: { model } }) + }) + + it('rejects all definition edits including full-settings and read-only escalation', async () => { + const f = fixture() + const settings = buddySettingsSchema.parse(f.buddy.settings) + for (const patch of [ + { name: 'Changed' }, + { description: 'Changed' }, + { settings: { ...settings, instructions: 'Changed' } }, + { settings: { ...settings, tools: { mode: 'all' as const } } }, + { settings: { ...settings, skills: { mode: 'all' as const } } }, + { settings: { ...settings, requiredSkills: [] } }, + { settings: { ...settings, limits: { fileAccess: 'inherit' as const } } }, + ]) { + await expect(f.service.update({ buddyId: f.buddy.id, expectedRevision: f.buddy.revision, ...patch })).rejects.toMatchObject({ code: 'BUDDY_MANAGED_PROTECTED' }) + } + expect(() => f.repository.delete({ buddyId: f.buddy.id, expectedRevision: f.buddy.revision })).toThrow(expect.objectContaining({ code: 'BUDDY_MANAGED_PROTECTED' })) + expect(f.service.get(f.buddy.id)).toEqual(f.buddy) + }) + + it.each(['disabled', 'removed', 'unavailable'] as const)('blocks %s sources without overwriting user preference and publishes a revision', (state) => { + const f = fixture() + const changes: unknown[] = [] + f.service.onDidChange(event => changes.push(event)) + f.sources.setState(f.buddy.id, state) + f.service.refreshManaged(f.buddy.id) + expect(f.service.get(f.buddy.id)).toMatchObject({ enabled: false, managed: { state, userEnabled: true }, revision: f.buddy.revision + 1 }) + expect(changes).toEqual([{ buddyId: f.buddy.id, catalogRevision: f.service.list().revision }]) + const current = f.service.get(f.buddy.id) + const copy = f.service.duplicate({ buddyId: current.id, expectedRevision: current.revision }) + expect(copy).toMatchObject({ enabled: true, settings: current.settings }) + expect(copy).not.toHaveProperty('managed') + }) + + it('allows explicit bindings to present managed definitions whose dependencies are not configured yet', async () => { + const f = fixture() + f.sources.setUnavailableReason(f.buddy.id, 'dependency_unavailable') + f.service.refreshManaged(f.buddy.id) + expect(f.service.get(f.buddy.id).enabled).toBe(false) + const current = f.service.get(f.buddy.id) + const copy = f.service.duplicate({ buddyId: current.id, expectedRevision: current.revision }) + expect(copy.settings).toEqual(current.settings) + expect(copy).not.toHaveProperty('managed') + const actor = await f.service.create({ name: 'Coordinator' }) + const settings = { ...buddySettingsSchema.parse(actor.settings), npcIds: [f.buddy.id] } + const bound = await f.service.update({ buddyId: actor.id, expectedRevision: actor.revision, settings }) + expect(bound.settings.npcIds).toEqual([f.buddy.id]) + f.sources.setState(f.buddy.id, 'removed') + const other = await f.service.create({ name: 'Other' }) + await expect(f.service.update({ buddyId: other.id, expectedRevision: other.revision, settings })).rejects.toMatchObject({ code: 'BUDDY_INVALID_NPC_BINDING' }) + expect(await f.service.update({ buddyId: actor.id, expectedRevision: bound.revision, settings })).toEqual(bound) + }) + + it('creates independent editable user NPCs with references and preserves original settings and user model', async () => { + const f = fixture() + const model = { providerId: 'user-provider', modelId: 'user-model', reasoning: null, serviceTier: null } + const original = await f.service.update({ buddyId: f.buddy.id, expectedRevision: f.buddy.revision, settings: { ...buddySettingsSchema.parse(f.buddy.settings), model } }) + expect(() => f.service.duplicate({ buddyId: original.id, expectedRevision: f.buddy.revision })).toThrow(expect.objectContaining({ code: 'BUDDY_CONFLICT' })) + const copy = f.service.duplicate({ buddyId: original.id, expectedRevision: original.revision }) + expect(copy.id).not.toBe(original.id) + expect(copy).not.toHaveProperty('managed') + expect(copy.settings).toEqual(original.settings) + expect(f.sources.get(copy.id)).toBeNull() + const edited = await f.service.update({ buddyId: copy.id, expectedRevision: copy.revision, name: 'My researcher', settings: { ...buddySettingsSchema.parse(copy.settings), instructions: 'My instructions' } }) + f.service.installManagedNpc({ ...f.input, name: 'New upstream name' }) + f.sources.setState(original.id, 'removed') + f.service.refreshManaged(original.id) + expect(f.service.get(copy.id)).toEqual(edited) + expect(f.service.get(copy.id).enabled).toBe(true) + const deleted = f.service.delete({ buddyId: copy.id, expectedRevision: edited.revision }) + expect(() => f.service.duplicate({ buddyId: copy.id, expectedRevision: deleted.revision })).toThrow(expect.objectContaining({ code: 'BUDDY_DELETED' })) + }) + + it('clears only user configuration on explicit removal while retaining identity and definition', async () => { + const f = fixture() + await f.service.update({ buddyId: f.buddy.id, expectedRevision: f.buddy.revision, avatar: 'preset:npc-lilac', settings: { ...f.input.settings, model: { providerId: 'user', modelId: 'model', reasoning: null, serviceTier: null } } }) + f.service.clearManagedConfiguration(f.buddy.id) + expect(f.service.get(f.buddy.id)).toMatchObject({ id: f.buddy.id, deletedAt: null, avatar: null, customAvatars: [], enabled: false, managed: { userEnabled: false }, settings: { ...f.input.settings, model: null } }) + }) +}) diff --git a/apps/buddy/service/src/buddies/__tests__/managedBuddyFixture.ts b/apps/buddy/service/src/buddies/__tests__/managedBuddyFixture.ts new file mode 100644 index 000000000..8f02d101c --- /dev/null +++ b/apps/buddy/service/src/buddies/__tests__/managedBuddyFixture.ts @@ -0,0 +1,11 @@ +import type { DatabaseSync } from 'node:sqlite' +import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../../shared/buddies/buddySettings' +import { createManagedCapabilityRepository } from '../../storage/managedCapabilityRepository' + +export function managedBuddyFixture(database: DatabaseSync) { + const sources = createManagedCapabilityRepository(database) + const definition = { id: 'research', name: 'Plugin researcher', description: 'Research role', instructions: 'Report evidence', fileAccess: 'read_only' as const, tools: [{ source: 'builtin' as const, name: 'read' as const }], skills: ['research'], requiredSkills: ['research'] } + const source = sources.upsertSource({ extensionId: 'fixture-plugin', extensionName: 'Fixture plugin', contributionId: definition.id, revision: 'revision-1', kind: 'subagent', definition, state: 'enabled' }) + const settings = buddySettingsSchema.parse({ ...DEFAULT_BUDDY_SETTINGS, instructions: definition.instructions, tools: { mode: 'selected', ids: ['["builtin","core","read"]'] }, skills: { mode: 'selected', ids: ['fixture-plugin:research'] }, requiredSkills: ['fixture-plugin:research'], limits: { fileAccess: 'read_only' } }) + return { sources, source, input: { id: source.id, name: definition.name, description: definition.description, settings } } +} diff --git a/apps/buddy/service/src/buddies/readAvailableBuddy.ts b/apps/buddy/service/src/buddies/readAvailableBuddy.ts new file mode 100644 index 000000000..57de46fb2 --- /dev/null +++ b/apps/buddy/service/src/buddies/readAvailableBuddy.ts @@ -0,0 +1,18 @@ +import type { LocalBuddy } from '../../../shared/buddies/buddyApi' +import { BuddyServiceError } from '../rpc/runtimeRequest' + +export function readAvailableBuddy(get: (id: string) => LocalBuddy, id: string): LocalBuddy | null { + try { + const buddy = get(id) + return buddy.enabled && !buddy.deletedAt ? buddy : null + } + catch (error) { + if (error instanceof BuddyServiceError && error.code === 'BUDDY_NOT_FOUND') + return null + throw error + } +} + +export function readAvailableNpcIds(get: (id: string) => LocalBuddy, ids: readonly string[]): string[] { + return ids.filter(id => readAvailableBuddy(get, id)?.kind === 'npc') +} diff --git a/apps/buddy/service/src/buddies/registerBuddiesRpc.ts b/apps/buddy/service/src/buddies/registerBuddiesRpc.ts index 01abc7331..e90b61a86 100644 --- a/apps/buddy/service/src/buddies/registerBuddiesRpc.ts +++ b/apps/buddy/service/src/buddies/registerBuddiesRpc.ts @@ -14,6 +14,7 @@ export function registerBuddiesRpc(rpc: RuntimeRequestRegistrar, service: Buddie registerRuntimeRequest(rpc, buddiesRpc.list, () => service.list()), registerRuntimeRequest(rpc, buddiesRpc.get, input => service.get(input.buddyId)), registerRuntimeRequest(rpc, buddiesRpc.create, (input, signal) => service.create(input, signal)), + registerRuntimeRequest(rpc, buddiesRpc.duplicate, (input, signal) => service.duplicate(input, signal)), registerRuntimeRequest(rpc, buddiesRpc.delete, (input, signal) => service.delete(input, signal)), registerRuntimeRequest(rpc, buddiesRpc.resetProfile, (input, signal) => service.resetProfile(input, signal)), registerRuntimeRequest(rpc, buddiesRpc.setDefault, (input, signal) => service.setDefault(input, signal)), diff --git a/apps/buddy/service/src/connectors/mcp/McpClientSession.ts b/apps/buddy/service/src/connectors/mcp/McpClientSession.ts index 8be0553ea..168ae3000 100644 --- a/apps/buddy/service/src/connectors/mcp/McpClientSession.ts +++ b/apps/buddy/service/src/connectors/mcp/McpClientSession.ts @@ -16,6 +16,7 @@ export interface McpClientSessionOptions { config: McpServerConfig credential: ConnectorCredential | null authProvider?: OAuthClientProvider + assertAvailable?: () => void maxReconnectAttempts?: number onUnavailable?: (code: ConnectorErrorCode) => void onToolsChanged?: () => void @@ -36,6 +37,7 @@ export class McpClientSession { } async connect(): Promise { + this.#options.assertAvailable?.() if (this.#lifetime.signal.aborted) throw new McpClientError('MCP_SERVER_UNAVAILABLE') if (this.#connected) @@ -80,6 +82,7 @@ export class McpClientSession { this.#client = client const transport = createMcpTransport(this.#options.config, this.#options.credential, this.#options.authProvider, { signal: this.#lifetime.signal, + assertAvailable: this.#options.assertAvailable, discovered: kind => this.#options.onAuthorization?.(kind), }) try { @@ -157,6 +160,7 @@ export class McpClientSession { } #requireClient(): Client { + this.#options.assertAvailable?.() if (!this.#client || !this.#connected || this.#lifetime.signal.aborted) throw new McpClientError('MCP_SERVER_UNAVAILABLE') return this.#client diff --git a/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts b/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts index b9d1e6953..31f527b55 100644 --- a/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts +++ b/apps/buddy/service/src/connectors/mcp/McpConnectionManager.ts @@ -22,6 +22,7 @@ interface Catalog { interface Connection { generation: number + record: McpServerRecord controller: AbortController session: Promise } @@ -144,7 +145,9 @@ export class McpConnectionManager { available(id: string, generation: number): boolean { const record = this.#options.repository.findById(id) + const connection = this.#connections.get(id) return !this.#closed && !this.#paused.has(id) && generation === this.generation(id) && record?.enabled === true + && (!connection || sameManagedSource(connection.record, record)) && (record.transport !== 'stdio' || record.executionConfirmedAt !== null) } @@ -196,6 +199,7 @@ export class McpConnectionManager { connection.controller.signal.throwIfAborted() if (generation !== this.generation(id)) return + this.#assertCurrent(record, generation) const previous = this.#catalogs.get(id) const updatedAt = new Date().toISOString() this.#options.repository.saveCatalog(id, JSON.stringify(tools), updatedAt) @@ -292,20 +296,25 @@ export class McpConnectionManager { #connection(record: McpServerRecord): Connection { const existing = this.#connections.get(record.id) - if (existing) + if (existing) { + this.#assertCurrent(existing.record, existing.generation) return existing + } const generation = this.generation(record.id) const controller = new AbortController() const connection: Connection = { generation, + record, controller, session: Promise.resolve().then(async () => { const credential = record.credentialRef ? await this.#options.readCredential(record.credentialRef) : null controller.signal.throwIfAborted() + this.#assertCurrent(record, generation) if (credential?.type === 'oauth') this.setAuthorization(record.id, 'oauth') return new McpClientSession({ config: this.#options.config(record), + assertAvailable: () => this.#assertCurrent(record, generation), credential, authProvider: this.#options.authProvider(record, credential, controller.signal), onAuthorization: (kind) => { @@ -341,11 +350,19 @@ export class McpConnectionManager { const record = this.#options.repository.findById(id) if (this.#closed || this.#paused.has(id) || !record) throw new McpClientError('MCP_SERVER_UNAVAILABLE') + if (record.managed && !record.enabled) + throw new McpClientError('MCP_CONNECTOR_DISABLED') if (record.transport === 'stdio' && !record.executionConfirmedAt) throw new McpClientError('MCP_EXECUTION_CONFIRMATION_REQUIRED') return record } + #assertCurrent(record: McpServerRecord, generation: number): void { + const current = this.#requireRecord(record.id) + if (generation !== this.generation(record.id) || !sameManagedSource(record, current)) + throw new McpClientError('MCP_CONNECTOR_CHANGED') + } + #publishState(id: string, previous: ConnectorRuntimeState): void { const snapshot = this.state(id) if (JSON.stringify(previous) !== JSON.stringify(snapshot)) @@ -366,6 +383,13 @@ export class McpConnectionManager { } } +function sameManagedSource(previous: McpServerRecord, current: McpServerRecord): boolean { + if (!previous.managed) + return !current.managed + return !!current.managed && previous.managed.extensionId === current.managed.extensionId + && previous.url === current.url && previous.credentialRef === current.credentialRef +} + function catalogRevision(tools: readonly McpCatalogTool[]): string { return createHash('sha256').update(JSON.stringify(tools)).digest('hex') } diff --git a/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts b/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts index 5fc9b84c8..09f7a5b3b 100644 --- a/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts +++ b/apps/buddy/service/src/connectors/mcp/McpConnectorService.ts @@ -7,11 +7,13 @@ import type { BuddyCapabilityResourceRevision } from '../../agent/extensions/Bud import type { BuddyToolDisclosurePolicy } from '../../agent/extensions/discovery/toolDiscoveryContract' import type { BuddyToolClassification } from '../../approvals/toolClassification' import type { ConnectorRepository, McpServerRecord, McpServerWrite } from '../../storage/connectorRepository' +import type { ManagedCapabilityRecord, ManagedCapabilityRepository } from '../../storage/managedCapabilityRepository' import type { McpConnectionEvent, McpConnectorDetails, McpConnectorEvent } from './mcpEvents' import type { McpServerConfig } from './mcpSchemas' import type { McpResultWriter } from './mcpToolResults' import { createHash, randomUUID } from 'node:crypto' import { connectorCredentialSchema } from '../../../../shared/connectors/connectorCredentials' +import { managedMcpCredentialTarget } from '../../../../shared/connectors/managedMcp' import { Emitter } from '../../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { credentialMutationResultSchema, credentialReadResultSchema } from '../../../../shared/runtime/credentialProtocol' @@ -31,6 +33,7 @@ export interface ConnectorSecretStore { export interface McpConnectorServiceOptions { connectors: ConnectorRepository + managed?: ManagedCapabilityRepository maxReconnectAttempts?: number onListenerError?: ListenerErrorHandler secrets: ConnectorSecretStore @@ -62,6 +65,7 @@ export class McpConnectorService { readonly #secrets: ConnectorSecretStore readonly #manager: McpConnectionManager readonly #toolNames = new McpToolNames() + readonly #managedTargets = new Map() readonly #mutations = new Map>() readonly #secretWrites = new Map>() readonly #logins = new Map, operationId: string }>() @@ -77,6 +81,10 @@ export class McpConnectorService { this.#events = new Emitter(options.onListenerError ?? (() => console.error('MCP_CONNECTOR_OBSERVER_FAILED'))) this.#connectors = options.connectors this.#secrets = options.secrets + for (const source of options.managed?.listKind('mcp') ?? []) { + if (source.kind === 'mcp' && 'authentication' in source.definition) + this.#managedTargets.set(source.id, managedMcpCredentialTarget(source.definition)) + } this.#manager = new McpConnectionManager({ repository: options.connectors, config: toConfig, @@ -106,7 +114,95 @@ export class McpConnectorService { } list(): readonly McpServerRecord[] { return this.#connectors.list() } - state(id: string): ConnectorRuntimeState { return this.#manager.state(id) } + state(id: string): ConnectorRuntimeState { + const state = this.#manager.state(id) + const source = this.#options.managed?.get(id) + if (!source || source.kind !== 'mcp' || !('authentication' in source.definition)) + return state + return Object.freeze({ ...state, authorization: source.definition.authentication === 'oauth' ? 'oauth' : source.definition.authentication === 'bearer' ? 'credentials' : null }) + } + + async applyManaged(source: ManagedCapabilityRecord): Promise { + const changesTarget = () => { + const stored = this.#connectors.findStoredById(source.id) + const definition = this.#managedDefinition(this.#requireManaged(source.id)) + const target = this.#managedTargets.get(source.id) + return !stored || stored.transport !== definition.transport || stored.url !== definition.url + || (target !== undefined && target !== managedMcpCredentialTarget(definition)) + } + await this.#mutate(source.id, async () => { + const current = this.#requireManaged(source.id) + const existing = this.#connectors.findById(source.id) + const stored = this.#connectors.findStoredById(source.id) + const definition = this.#managedDefinition(current) + const targetChanged = changesTarget() + const reason = existing?.managed + ? existing.managed.unavailableReason + : definition.authentication === 'none' ? null : 'authentication_required' + if (!targetChanged && stored?.name === definition.name && reason === current.unavailableReason) + return + this.#options.managed!.setUnavailableReason(source.id, reason) + const now = new Date().toISOString() + const saved = this.#connectors.upsert({ + id: source.id, + name: definition.name, + transport: definition.transport, + url: definition.url, + command: null, + args: null, + cwd: null, + executionConfirmedAt: null, + enabled: false, + credentialRef: existing?.credentialRef ?? null, + toolNamespace: existing?.toolNamespace, + toolExposure: existing?.toolExposure ?? 'deferred', + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + this.#managedTargets.set(source.id, managedMcpCredentialTarget(definition)) + if (targetChanged) + this.#manager.clearCatalog(source.id) + this.#configurationCommitted(stored ? { ...stored, enabled: existing?.enabled ?? stored.enabled } : null, saved) + }, changesTarget) + } + + async refreshManaged(id: string): Promise { + this.#requireManaged(id) + await this.#mutate(id, async () => { + const record = this.#connectors.findById(id) + if (record) + this.#publish(id, { type: 'configuration', kind: 'updated', enabled: record.enabled, executionChanged: true }) + }) + } + + async clearManagedConfiguration(id: string): Promise { + this.#requireManaged(id) + await this.#mutate(id, async () => { + const record = this.#connectors.findById(id) + if (!record) { + this.#options.managed!.clearConfiguration(id) + return + } + const previous = record.credentialRef ? await this.#secrets.read(record.credentialRef) : null + let saved: McpServerRecord + try { + if (record.credentialRef) + await this.#deleteCredential(record.credentialRef, id) + saved = this.#connectors.configureManaged( + { ...record, credentialRef: null, enabled: false, toolExposure: 'deferred', updatedAt: new Date().toISOString() }, + { credentialTarget: null, userEnabled: false, unavailableReason: null }, + ) + } + catch (error) { + if (record.credentialRef) + await this.#restoreSecret(record.credentialRef, previous, id) + throw error + } + this.#manager.clearCatalog(id) + this.#configurationCommitted(record, saved) + }) + } + tools(id: string): ConnectorToolSummary[] { this.#requireConnector(id) return this.#manager.catalog(id).map(tool => ({ name: tool.name, title: tool.title ?? tool.annotations?.title ?? tool.name, description: tool.description ?? '', readOnly: tool.annotations?.readOnlyHint === true })) @@ -121,6 +217,8 @@ export class McpConnectorService { if (!parsed.success) throw new McpConnectorError('VALIDATION_FAILED') input = { config: parsed.data, credential: copyEventSnapshot(input.credential) } + if (this.#options.managed?.get(parsed.data.id) || this.#connectors.findById(parsed.data.id)?.managed) + return this.#saveManaged(input) return this.#mutate(parsed.data.id, async () => { const existing = this.#connectors.findById(parsed.data.id) const previousRef = existing?.credentialRef ?? null @@ -168,11 +266,28 @@ export class McpConnectorService { } setEnabled(id: string, enabled: boolean): Promise { + if (this.#options.managed?.get(id) || this.#connectors.findById(id)?.managed) { + return this.#mutate(id, async () => { + this.#requireManaged(id) + const previous = this.#requireConnector(id) + if (enabled && (previous.managed?.state !== 'enabled' || previous.managed.unavailableReason)) + throw new McpClientError(previous.managed?.unavailableReason === 'authentication_required' ? 'MCP_AUTHENTICATION_REQUIRED' : 'MCP_CONNECTOR_DISABLED') + this.#options.managed!.setUserEnabled(id, enabled) + const saved = this.#requireConnector(id) + this.#configurationCommitted(previous, saved) + return saved + }, () => this.#connectors.findById(id)?.managed?.userEnabled !== enabled) + } return this.#mutate(id, async () => this.#persistConfig(toConfig({ ...this.#requireConnector(id), enabled })), () => this.#connectors.findById(id)?.enabled !== enabled) } async saveCredential(id: string, credential: ConnectorCredential): Promise { credential = copyEventSnapshot(credential) + const record = this.#requireConnector(id) + if (record.managed) { + await this.#saveManaged({ config: toConfig({ ...record, enabled: record.managed.userEnabled }), credential: { mode: 'replace', value: credential } }) + return + } await this.#mutate(id, async () => { const record = this.#requireConnector(id) const parsed = connectorCredentialSchema.safeParse(credential) @@ -193,6 +308,11 @@ export class McpConnectorService { } async clearCredential(id: string): Promise { + const record = this.#requireConnector(id) + if (record.managed) { + await this.#saveManaged({ config: toConfig({ ...record, enabled: record.managed.userEnabled }), credential: { mode: 'clear' } }) + return + } await this.#mutate(id, async () => { const record = this.#requireConnector(id) if (!record.credentialRef) @@ -210,7 +330,13 @@ export class McpConnectorService { }, () => this.#connectors.findById(id)?.credentialRef != null) } - remove(id: string): Promise { + async remove(id: string): Promise { + if (this.#options.managed?.get(id) || this.#connectors.findById(id)?.managed) { + if (this.#requireManaged(id).state !== 'removed') + throw new McpConnectorError('MCP_MANAGED_READ_ONLY') + await this.clearManagedConfiguration(id) + return true + } return this.#mutate(id, async () => { const record = this.#connectors.findById(id) if (!record) @@ -266,6 +392,12 @@ export class McpConnectorService { if (this.#quiescing) throw new McpClientError('MCP_SERVER_UNAVAILABLE') const record = this.#requireConnector(id) + const source = record.managed ? this.#requireManaged(id) : null + if (source) { + this.#assertManagedCurrent(source) + if (this.#managedDefinition(source).authentication !== 'oauth') + throw new McpConnectorError('VALIDATION_FAILED') + } if (record.transport !== 'streamable-http' || !record.url || !this.#options.openExternal) throw new McpConnectorError('VALIDATION_FAILED') if (this.state(id).authorization !== 'oauth') { @@ -289,13 +421,14 @@ export class McpConnectorService { this.#manager.setAuthorization(id, 'oauth') this.#manager.setState(id, 'authenticating') this.#publish(id, { type: 'login', operationId, status: 'started' }) - const credential = await loginMcpOAuth({ url: record.url!, signal: controller.signal, openExternal: this.#options.openExternal! }) + const credential = await loginMcpOAuth({ url: record.url!, signal: controller.signal, openExternal: this.#options.openExternal!, assertAvailable: source ? () => this.#assertManagedCurrent(source) : undefined }) if (credential) await this.#saveOAuth(record, generation, controller.signal, credential) controller.signal.throwIfAborted() this.#manager.clearCatalog(id) this.#manager.resume(id) - await this.#manager.reconnect(id) + if (!record.managed || this.#requireConnector(id).enabled) + await this.#manager.reconnect(id) this.#publish(id, { type: 'login', operationId, status: 'succeeded' }) } catch (error) { @@ -355,8 +488,12 @@ export class McpConnectorService { callTool: (tool, parameters, signal, onProgress) => this.#manager.callTool(connector.id, { generation, exposure: connector.toolExposure }, tool, parameters, signal, onProgress), writeResult, }) - for (const tool of result.tools) - availability.set(tool.name, () => connector.toolExposure !== 'hidden' && this.#manager.available(connector.id, generation) && this.#connectors.findById(connector.id)?.toolExposure === connector.toolExposure) + for (const tool of result.tools) { + availability.set(tool.name, () => { + const current = this.#connectors.findById(connector.id) + return connector.toolExposure !== 'hidden' && this.#manager.available(connector.id, generation) && current?.toolExposure === connector.toolExposure + }) + } tools.push(...result.tools) disclosure.push(result.disclosure) diagnostics.push(...result.diagnostics) @@ -411,8 +548,95 @@ export class McpConnectorService { }) } + #requireManaged(id: string): ManagedCapabilityRecord { + const source = this.#options.managed?.get(id) + if (!source || source.kind !== 'mcp') + throw new McpConnectorError('MCP_MANAGED_READ_ONLY') + return source + } + + #managedDefinition(source: ManagedCapabilityRecord) { + if (source.kind !== 'mcp' || !('authentication' in source.definition)) + throw new McpConnectorError('VALIDATION_FAILED') + return source.definition + } + + #assertManagedCurrent(source: ManagedCapabilityRecord, allowDisabled = false): void { + const current = this.#requireManaged(source.id) + if (current.revision !== source.revision || managedMcpCredentialTarget(this.#managedDefinition(current)) !== managedMcpCredentialTarget(this.#managedDefinition(source))) + throw new McpClientError('MCP_CONNECTOR_CHANGED') + if (current.state !== 'enabled' && !(allowDisabled && current.state === 'disabled')) + throw new McpClientError('MCP_CONNECTOR_DISABLED') + } + + #saveManaged(input: SaveMcpConnectorInput): Promise { + const { config, credential } = input + return this.#mutate(config.id, async () => { + const source = this.#requireManaged(config.id) + const definition = this.#managedDefinition(source) + const existing = this.#requireConnector(config.id) + if (config.name !== definition.name || config.transport !== definition.transport + || config.url !== definition.url || (config.toolNamespace !== undefined && config.toolNamespace !== existing.toolNamespace)) { + throw new McpConnectorError('MCP_MANAGED_READ_ONLY') + } + if (source.state === 'removed' && credential.mode !== 'clear') + throw new McpConnectorError('MCP_MANAGED_READ_ONLY') + const replacing = credential.mode === 'replace' + if (replacing) { + this.#assertManagedCurrent(source, true) + const value = connectorCredentialSchema.parse(credential.value) + if (definition.authentication !== 'bearer' || value.type !== 'http' || !value.bearerToken || Object.keys(value.headers ?? {}).length) + throw new McpConnectorError('VALIDATION_FAILED') + } + if (config.enabled && !source.userEnabled && (source.state !== 'enabled' + || (credential.mode === 'keep' && existing.managed?.unavailableReason) + || (credential.mode === 'clear' && definition.authentication !== 'none'))) { + throw new McpClientError('MCP_CONNECTOR_DISABLED') + } + const ref = replacing ? existing.credentialRef ?? source.id : credential.mode === 'clear' ? null : existing.credentialRef + const changed = credential.mode !== 'keep' + const previous = changed && existing.credentialRef ? await this.#secrets.read(existing.credentialRef) : null + let saved: McpServerRecord + try { + if (replacing) + await this.#writeCredential(ref!, credential.value, source.id) + else if (credential.mode === 'clear' && existing.credentialRef) + await this.#deleteCredential(existing.credentialRef, source.id) + if (replacing) + this.#assertManagedCurrent(source, true) + else if (this.#requireManaged(source.id).revision !== source.revision) + throw new McpClientError('MCP_CONNECTOR_CHANGED') + saved = this.#connectors.configureManaged( + { ...existing, credentialRef: ref, toolExposure: config.toolExposure ?? existing.toolExposure, enabled: false, updatedAt: new Date().toISOString() }, + { + credentialTarget: changed ? ref ? managedMcpCredentialTarget(definition) : null : source.credentialTarget, + unavailableReason: changed ? ref || definition.authentication === 'none' ? null : 'authentication_required' : existing.managed?.unavailableReason ?? source.unavailableReason, + userEnabled: config.enabled, + }, + ) + } + catch (error) { + if (changed) + await this.#restoreSecret(ref ?? existing.credentialRef ?? source.id, previous, source.id) + throw error + } + if (changed) + this.#manager.clearCatalog(source.id) + this.#configurationCommitted(existing, saved) + return saved + }) + } + #saveOAuth(target: McpServerRecord, generation: number, signal: AbortSignal, credential: OAuthConnectorCredential): Promise { const id = target.id + const source = target.managed ? this.#requireManaged(id) : null + const assertCurrent = () => { + if (source) { + this.#assertManagedCurrent(source) + if (source.revision !== target.managed?.revision || this.#managedDefinition(source).authentication !== 'oauth') + throw new McpClientError('MCP_CONNECTOR_CHANGED') + } + } return enqueue(this.#secretWrites, id, async () => { signal.throwIfAborted() if (this.#closed || generation !== this.#manager.generation(id)) @@ -420,6 +644,7 @@ export class McpConnectorService { const record = this.#requireConnector(id) if (record.transport !== 'streamable-http' || record.url !== target.url) throw new McpClientError('MCP_CONNECTOR_CHANGED') + assertCurrent() const ref = record.credentialRef ?? record.id const previous = await this.#secrets.read(ref) try { @@ -428,7 +653,16 @@ export class McpConnectorService { signal.throwIfAborted() if (generation !== this.#manager.generation(id)) throw new McpClientError('MCP_CONNECTOR_CHANGED') - if (record.credentialRef !== ref) { + assertCurrent() + if (source) { + const current = this.#requireManaged(id) + const saved = this.#connectors.configureManaged( + { ...record, credentialRef: ref, updatedAt: new Date().toISOString() }, + { userEnabled: current.userEnabled, credentialTarget: managedMcpCredentialTarget(this.#managedDefinition(source)), unavailableReason: credential.tokens ? null : 'authentication_required' }, + ) + this.#configurationCommitted(record, saved) + } + else if (record.credentialRef !== ref) { const saved = this.#connectors.upsert({ ...record, credentialRef: ref, updatedAt: new Date().toISOString() }) this.#configurationCommitted(record, saved) } diff --git a/apps/buddy/service/src/connectors/mcp/McpOAuthProvider.ts b/apps/buddy/service/src/connectors/mcp/McpOAuthProvider.ts index 16c188a36..b1886377f 100644 --- a/apps/buddy/service/src/connectors/mcp/McpOAuthProvider.ts +++ b/apps/buddy/service/src/connectors/mcp/McpOAuthProvider.ts @@ -100,6 +100,7 @@ export async function loginMcpOAuth(options: { url: string signal: AbortSignal openExternal: (url: string) => Promise + assertAvailable?: () => void }): Promise { const signal = AbortSignal.any([options.signal, AbortSignal.timeout(180_000)]) let provider: McpOAuthProvider | undefined @@ -143,6 +144,7 @@ export async function loginMcpOAuth(options: { signal, save: async () => {}, redirect: async (url) => { + options.assertAvailable?.() await options.openExternal(url.href) redirected = true }, @@ -150,7 +152,10 @@ export async function loginMcpOAuth(options: { const transport = new StreamableHTTPClientTransport(new URL(options.url), { authProvider: provider, requestInit: { redirect: 'error' }, - fetch: (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal }), + fetch: (input, init) => { + options.assertAvailable?.() + return fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal }) + }, }) client = new Client({ name: 'lexora-buddy', version }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, maxRetries: 0 } } }) try { diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts index 12457fad9..dc4699e4b 100644 --- a/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpConnectorService.spec.ts @@ -345,6 +345,8 @@ function createCredentialFailureFixture() { } const service = new McpConnectorService({ connectors: { + findStoredById: () => record, + configureManaged: () => { throw new Error('database unavailable') }, readCatalog: () => null, saveCatalog: () => {}, clearCatalog: () => {}, @@ -395,6 +397,8 @@ function createAtomicSaveFailureFixture() { } const service = new McpConnectorService({ connectors: { + findStoredById: () => record, + configureManaged: () => { throw new Error('database unavailable') }, readCatalog: () => null, saveCatalog: () => {}, clearCatalog: () => {}, diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpManaged.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpManaged.spec.ts new file mode 100644 index 000000000..bee2d893a --- /dev/null +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpManaged.spec.ts @@ -0,0 +1,361 @@ +import type { ConnectorCredential } from '../../../../../shared/connectors/connectorCredentials' +import type { ExtensionMcpConnectorContribution } from '../../../../../shared/extensions/extensionMcpConnectors' +import type { RuntimeRequestHandler } from '../../../../../shared/runtime/rpcPeer' +import type { ManagedCapabilityInput } from '../../../storage/managedCapabilityRepository' +import type { McpConnectionEvent, McpConnectorEvent } from '../mcpEvents' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { connectorSchema } from '../../../../../shared/connectors/connectorApi' +import { createConnectorRepository } from '../../../storage/connectorRepository' +import { openBuddyDatabase } from '../../../storage/database' +import { createManagedCapabilityRepository } from '../../../storage/managedCapabilityRepository' +import { sanitizeRestoredDatabase } from '../../../storage/restorePolicy' +import { McpConnectorService } from '../McpConnectorService' +import { registerMcpConnectorRpc } from '../registerMcpConnectorRpc' + +const cleanup: Array<() => Promise> = [] +afterEach(async () => { + for (const dispose of cleanup.splice(0).reverse()) + await dispose() + vi.unstubAllGlobals() +}) + +function fixture(authentication: ExtensionMcpConnectorContribution['authentication'] = 'bearer') { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const managed = createManagedCapabilityRepository(database) + const connectors = createConnectorRepository(database) + const secrets = new Map() + const requests: Array<{ url: string, authorization: string | null, method: string }> = [] + let beforeWrite: (() => Promise) | undefined + let oauth = authentication === 'oauth' + const localFetch = globalThis.fetch + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input) + const headers = new Headers(init?.headers) + const message = typeof init?.body === 'string' && init.body.startsWith('{') ? JSON.parse(init.body) : {} + requests.push({ url, authorization: headers.get('authorization'), method: message.method ?? init?.method ?? 'GET' }) + const target = new URL(url) + if (oauth) { + const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } }) + if (target.pathname.startsWith('/.well-known/oauth-protected-resource')) + return json({ resource: `${target.origin}/mcp`, authorization_servers: [target.origin], scopes_supported: ['read'] }) + if (target.pathname.startsWith('/.well-known/oauth-authorization-server')) + return json({ issuer: target.origin, authorization_endpoint: `${target.origin}/authorize`, token_endpoint: `${target.origin}/token`, registration_endpoint: `${target.origin}/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'] }) + if (target.pathname === '/register') + return json({ ...message, client_id: 'fixture-client', token_endpoint_auth_method: 'none' }, 201) + if (target.pathname === '/token') + return json({ access_token: 'fixture-oauth', token_type: 'Bearer', refresh_token: 'fixture-refresh', expires_in: 3600 }) + if (target.pathname === '/mcp' && headers.get('authorization') !== 'Bearer fixture-oauth') + return new Response('{}', { status: 401, headers: { 'content-type': 'application/json', 'WWW-Authenticate': `Bearer resource_metadata="${target.origin}/.well-known/oauth-protected-resource/mcp"` } }) + } + if (message.id === undefined) + return new Response(null, { status: 202 }) + const result = message.method === 'server/discover' + ? { supportedVersions: ['2026-07-28'], capabilities: { tools: {} }, _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'fixture', version: '1' } } } + : message.method === 'tools/list' + ? { ttlMs: 60000, cacheScope: 'private', tools: [{ name: 'echo', inputSchema: { type: 'object' } }] } + : { content: [{ type: 'text', text: 'fixture-result' }] } + return new Response(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { ...result, resultType: 'complete' } }), { headers: { 'content-type': 'application/json', 'MCP-Protocol-Version': '2026-07-28' } }) + }) + const service = new McpConnectorService({ + connectors, + managed, + secrets: { + read: async id => secrets.get(id) ?? null, + write: async (id, value) => { + await beforeWrite?.() + secrets.set(id, value) + }, + delete: async (id) => { secrets.delete(id) }, + }, + openExternal: async (url) => { + const authorization = new URL(url) + const callback = new URL(authorization.searchParams.get('redirect_uri')!) + callback.searchParams.set('code', 'fixture-code') + callback.searchParams.set('state', authorization.searchParams.get('state')!) + await localFetch(callback) + }, + }) + const events: McpConnectionEvent[] = [] + const changes: McpConnectorEvent[] = [] + service.onDidChange(event => changes.push(event)) + service.onDidChangeConnection(event => events.push(event)) + const input: ManagedCapabilityInput = { + extensionId: 'fixture.plugin', + extensionName: 'Fixture plugin', + contributionId: 'remote', + kind: 'mcp', + revision: 'v1', + state: 'enabled', + definition: { id: 'remote', name: 'Remote', transport: 'streamable-http', url: 'https://old.example.test/mcp', authentication }, + } + const source = managed.upsertSource(input) + const config = () => ({ + id: source.id, + name: 'Remote', + transport: 'streamable-http' as const, + url: 'https://old.example.test/mcp', + enabled: managed.get(source.id)!.userEnabled, + credentialRef: null, + }) + cleanup.push(async () => { + await service.close() + database.close() + }) + return { + database, + managed, + connectors, + secrets, + requests, + service, + source, + input, + config, + events, + changes, + enableOAuth: () => { oauth = true }, + beforeWrite: (action?: () => Promise) => { beforeWrite = action }, + } +} + +describe('managed MCP source and configuration', () => { + it('can explicitly re-enable a restored unauthenticated connector after its plugin is available', async () => { + const f = fixture('none') + await f.service.applyManaged(f.source) + await f.service.setEnabled(f.source.id, true) + sanitizeRestoredDatabase(f.database) + await f.service.refreshManaged(f.source.id) + expect(f.connectors.findById(f.source.id)?.enabled).toBe(false) + f.managed.setState(f.source.id, 'enabled') + await f.service.applyManaged(f.managed.get(f.source.id)!) + expect(f.connectors.findById(f.source.id)?.enabled).toBe(false) + await f.service.setEnabled(f.source.id, true) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: true, managed: { unavailableReason: null } }) + }) + + it('starts disabled, binds credentials, and preserves the user switch through plugin suspension and restoration', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + expect(f.connectors.findById(f.source.id)).toMatchObject({ + enabled: false, + toolExposure: 'deferred', + managed: { userEnabled: false, unavailableReason: 'authentication_required' }, + }) + expect(await f.service.test(f.source.id)).toMatchObject({ errorCode: 'MCP_CONNECTOR_DISABLED' }) + await expect(f.service.setEnabled(f.source.id, true)).rejects.toMatchObject({ code: 'MCP_AUTHENTICATION_REQUIRED' }) + expect(f.requests).toEqual([]) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-first' }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: false, managed: { userEnabled: false, unavailableReason: null } }) + expect(f.managed.get(f.source.id)?.credentialTarget).toContain('old.example.test') + await f.service.setEnabled(f.source.id, true) + expect(await f.service.test(f.source.id)).toMatchObject({ status: 'ready' }) + expect(f.requests.some(request => request.authorization === 'Bearer fixture-first')).toBe(true) + const tools = f.service.getTools() + const tool = tools.tools[0]! + expect(tools.available(tool.name)).toBe(true) + f.managed.setState(f.source.id, 'disabled') + expect(tools.available(tool.name)).toBe(false) + const requests = f.requests.length + await f.service.refreshManaged(f.source.id) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: false, managed: { state: 'disabled', userEnabled: true } }) + expect(await f.service.test(f.source.id)).toMatchObject({ errorCode: 'MCP_CONNECTOR_DISABLED' }) + expect(f.requests).toHaveLength(requests) + f.managed.setState(f.source.id, 'unavailable') + await f.service.applyManaged(f.managed.get(f.source.id)!) + f.managed.setState(f.source.id, 'enabled') + await f.service.refreshManaged(f.source.id) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: true, managed: { userEnabled: true, unavailableReason: null } }) + expect(tools.available(tool.name)).toBe(false) + expect(f.events.filter(event => event.type === 'generation').length).toBeGreaterThan(2) + }) + + it('materializes idempotently and preserves the connection and catalog for metadata-only updates', async () => { + const f = fixture('none') + await f.service.applyManaged(f.source) + await f.service.setEnabled(f.source.id, true) + await f.service.test(f.source.id) + const before = f.connectors.findStoredById(f.source.id) + const requests = f.requests.length + f.events.length = 0 + f.changes.length = 0 + await f.service.applyManaged(f.managed.get(f.source.id)!) + expect(f.connectors.findStoredById(f.source.id)).toEqual(before) + expect(f.events).toEqual([]) + expect(f.changes).toEqual([]) + const renamed = f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, name: 'Renamed' } }) + await f.service.applyManaged(renamed) + expect(f.service.state(f.source.id)).toMatchObject({ status: 'ready', toolCount: 1 }) + expect(f.events.filter(event => ['generation', 'catalog'].includes(event.type))).toEqual([]) + expect(f.changes.at(-1)).toMatchObject({ type: 'configuration', executionChanged: false }) + expect(f.requests).toHaveLength(requests) + f.managed.setState(f.source.id, 'disabled') + await f.service.applyManaged(f.managed.get(f.source.id)!) + await f.service.refreshManaged(f.source.id) + expect(f.connectors.readCatalog(f.source.id)).not.toBeNull() + expect(f.service.state(f.source.id)).toMatchObject({ status: 'disabled', toolCount: 1 }) + }) + + it('blocks stale connections before refresh and never sends saved credentials to a changed target', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-old' }) + await f.service.setEnabled(f.source.id, true) + await f.service.test(f.source.id) + const tools = f.service.getTools() + const changed = f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, url: 'https://new.example.test/mcp' } }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: false, managed: { unavailableReason: 'target_changed' } }) + expect(tools.available(tools.tools[0]!.name)).toBe(false) + const count = f.requests.length + await tools.tools[0]!.execute('fixture-call', {}, undefined, undefined, {} as never).catch(() => {}) + await f.service.test(f.source.id) + await f.service.applyManaged(changed) + await f.service.test(f.source.id) + expect(f.requests).toHaveLength(count) + expect(f.secrets.get(f.source.id)).toEqual({ type: 'http', bearerToken: 'fixture-old' }) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-new' }) + expect(await f.service.test(f.source.id)).toMatchObject({ status: 'ready' }) + const sent = f.requests.slice(count) + expect(sent.length).toBeGreaterThan(0) + expect(sent.every(request => request.url === 'https://new.example.test/mcp' && request.authorization === 'Bearer fixture-new')).toBe(true) + }) + + it('rejects changes to source fields and deletion while allowing user exposure preferences', async () => { + const f = fixture('none') + await f.service.applyManaged(f.source) + for (const patch of [{ name: 'Overridden' }, { url: 'https://other.example.test/mcp' }]) + await expect(f.service.upsert({ ...f.config(), ...patch })).rejects.toMatchObject({ code: 'MCP_MANAGED_READ_ONLY' }) + await expect(f.service.remove(f.source.id)).rejects.toMatchObject({ code: 'MCP_MANAGED_READ_ONLY' }) + await expect(f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'unexpected' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + await f.service.upsert({ ...f.config(), toolExposure: 'codemode' }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ name: 'Remote', toolExposure: 'codemode', managed: { userEnabled: false } }) + expect(f.secrets.size).toBe(0) + }) + + it('keeps a credential-free target change blocked across preference edits until explicit confirmation', async () => { + const f = fixture('none') + await f.service.applyManaged(f.source) + f.managed.setUserEnabled(f.source.id, true) + f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, url: 'https://new.example.test/mcp' } }) + await f.service.save({ config: { ...f.config(), url: 'https://new.example.test/mcp', toolExposure: 'direct' }, credential: { mode: 'keep' } }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: false, managed: { unavailableReason: 'target_changed' } }) + expect(f.requests).toEqual([]) + await f.service.clearCredential(f.source.id) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: true, credentialRef: null, managed: { unavailableReason: null } }) + expect(await f.service.test(f.source.id)).toMatchObject({ status: 'ready' }) + expect(f.requests.every(request => request.url === 'https://new.example.test/mcp' && request.authorization === null)).toBe(true) + }) + + it('retains credentials after uninstall and explicitly clears configuration without deleting source identity', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + await f.service.save({ config: { ...f.config(), toolExposure: 'direct' }, credential: { mode: 'replace', value: { type: 'http', bearerToken: 'fixture-retained' } } }) + f.managed.setState(f.source.id, 'removed') + await f.service.refreshManaged(f.source.id) + expect(f.secrets.has(f.source.id)).toBe(true) + expect(f.connectors.findById(f.source.id)).toMatchObject({ credentialRef: f.source.id, managed: { state: 'removed', extensionId: 'fixture.plugin' } }) + await f.service.remove(f.source.id) + expect(f.secrets.has(f.source.id)).toBe(false) + expect(f.connectors.findById(f.source.id)).toMatchObject({ credentialRef: null, enabled: false, toolExposure: 'deferred', managed: { state: 'removed' } }) + expect(f.managed.get(f.source.id)).toMatchObject({ id: f.source.id, credentialTarget: null, userEnabled: false }) + }) + + it('rejects a credential write crossing a source target change and restores the previous secret', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-retained' }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + f.beforeWrite(async () => { + f.beforeWrite() + entered.resolve() + await release.promise + }) + const pending = f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-raced' }) + await entered.promise + f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, authentication: 'oauth' } }) + release.resolve() + await expect(pending).rejects.toMatchObject({ code: 'MCP_CONNECTOR_CHANGED' }) + expect(f.secrets.get(f.source.id)).toEqual({ type: 'http', bearerToken: 'fixture-retained' }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ enabled: false, managed: { unavailableReason: 'target_changed' } }) + expect(f.requests).toEqual([]) + }) + + it('rebinds a changed authentication target only after fresh OAuth without sending the old bearer token', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-old-bearer' }) + const source = f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, authentication: 'oauth' } }) + await f.service.applyManaged(source) + f.enableOAuth() + f.service.login(f.source.id) + await vi.waitUntil(() => f.changes.some(event => event.type === 'login' && ['succeeded', 'failed'].includes(event.status))) + expect(f.changes.filter(event => event.type === 'login').at(-1)).toMatchObject({ status: 'succeeded' }) + expect(f.secrets.get(f.source.id)).toMatchObject({ type: 'oauth', tokens: { access_token: 'fixture-oauth' } }) + expect(f.managed.get(f.source.id)).toMatchObject({ unavailableReason: null, userEnabled: false }) + expect(f.requests.every(request => request.authorization !== 'Bearer fixture-old-bearer')).toBe(true) + expect(f.requests.some(request => request.url.endsWith('/token'))).toBe(true) + await f.service.setEnabled(f.source.id, true) + expect(await f.service.test(f.source.id)).toMatchObject({ status: 'ready' }) + }) + + it('rejects a late OAuth secret write when its source is suspended', async () => { + const f = fixture('oauth') + await f.service.applyManaged(f.source) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + f.beforeWrite(async () => { + f.beforeWrite() + entered.resolve() + await release.promise + }) + f.service.login(f.source.id) + await entered.promise + f.managed.setState(f.source.id, 'disabled') + release.resolve() + await vi.waitUntil(() => f.changes.some(event => event.type === 'login' && event.status === 'failed')) + expect(f.secrets.size).toBe(0) + expect(f.managed.get(f.source.id)).toMatchObject({ credentialTarget: null, userEnabled: false }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ credentialRef: null, enabled: false }) + }) + + it('atomically retains credential binding and configuration when the SQL commit fails', async () => { + const f = fixture() + await f.service.applyManaged(f.source) + await f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-original' }) + const target = f.managed.get(f.source.id)!.credentialTarget + await f.service.applyManaged(f.managed.upsertSource({ ...f.input, revision: 'v2', definition: { ...f.source.definition as ExtensionMcpConnectorContribution, url: 'https://changed.example.test/mcp' } })) + f.database.exec('CREATE TRIGGER fail_managed_user BEFORE UPDATE OF user_enabled ON extension_capabilities BEGIN SELECT RAISE(FAIL, \'fixture SQL failure\'); END') + await expect(f.service.saveCredential(f.source.id, { type: 'http', bearerToken: 'fixture-replacement' })).rejects.toThrow('fixture SQL failure') + expect(f.secrets.get(f.source.id)).toEqual({ type: 'http', bearerToken: 'fixture-original' }) + expect(f.managed.get(f.source.id)).toMatchObject({ credentialTarget: target, unavailableReason: 'target_changed' }) + await expect(f.service.clearManagedConfiguration(f.source.id)).rejects.toThrow('fixture SQL failure') + expect(f.secrets.get(f.source.id)).toEqual({ type: 'http', bearerToken: 'fixture-original' }) + expect(f.connectors.findById(f.source.id)).toMatchObject({ credentialRef: f.source.id, enabled: false, managed: { unavailableReason: 'target_changed' } }) + expect(f.requests).toEqual([]) + }) + + it.each(['disabled', 'removed', 'unavailable'] as const)('blocks login and networking for a %s plugin', async (state) => { + const f = fixture('oauth') + f.managed.setState(f.source.id, state) + await f.service.applyManaged(f.managed.get(f.source.id)!) + expect(() => f.service.login(f.source.id)).toThrow(expect.objectContaining({ code: 'MCP_CONNECTOR_DISABLED' })) + expect(await f.service.test(f.source.id)).toMatchObject({ errorCode: 'MCP_CONNECTOR_DISABLED' }) + expect(f.requests).toEqual([]) + }) + + it('publishes source metadata only for managed RPC records', async () => { + const f = fixture('none') + await f.service.applyManaged(f.source) + await f.service.upsert({ ...f.config(), id: 'user-connection' }) + const handlers = new Map() + registerMcpConnectorRpc({ onRequest: (method, handler) => { + handlers.set(method, handler) + return () => {} + } }, f.service) + const result = await handlers.get('connectors.list')!({}) + expect(Array.isArray(result)).toBe(true) + const records = (result as unknown[]).map(record => connectorSchema.parse(record)) + expect(records.find(record => record.id === f.source.id)?.managed?.extensionId).toBe('fixture.plugin') + expect(records.find(record => record.id === 'user-connection')).not.toHaveProperty('managed') + }) +}) diff --git a/apps/buddy/service/src/connectors/mcp/createMcpTransport.ts b/apps/buddy/service/src/connectors/mcp/createMcpTransport.ts index e73d61ec0..ec0c24495 100644 --- a/apps/buddy/service/src/connectors/mcp/createMcpTransport.ts +++ b/apps/buddy/service/src/connectors/mcp/createMcpTransport.ts @@ -10,6 +10,7 @@ import { createChildProcessEnvironment } from '../../../../platform/process/chil export function createMcpTransport(config: McpServerConfig, credential: ConnectorCredential | null, authProvider?: OAuthClientProvider, authorization?: { signal: AbortSignal + assertAvailable?: () => void discovered: (kind: ConnectorRuntimeState['authorization']) => void }) { if (config.transport === 'stdio') { @@ -49,6 +50,7 @@ export function createMcpTransport(config: McpServerConfig, credential: Connecto return new StreamableHTTPClientTransport(new URL(config.url), { authProvider, fetch: async (input, init) => { + authorization?.assertAvailable?.() const response = await fetch(input, init) if (authorization && !checkedAuthorization && (response.status === 401 || response.status === 403)) { checkedAuthorization = true @@ -58,7 +60,10 @@ export function createMcpTransport(config: McpServerConfig, credential: Connecto const signal = AbortSignal.any([authorization.signal, AbortSignal.timeout(5_000)]) const info = await discoverOAuthServerInfo(config.url, { resourceMetadataUrl: extractWWWAuthenticateParams(response).resourceMetadataUrl, - fetchFn: (url, options) => fetch(url, { ...options, redirect: 'error', signal }), + fetchFn: (url, options) => { + authorization.assertAvailable?.() + return fetch(url, { ...options, redirect: 'error', signal }) + }, }) if (info.authorizationServerMetadata?.authorization_endpoint) kind = 'oauth' diff --git a/apps/buddy/service/src/connectors/mcp/registerMcpConnectorRpc.ts b/apps/buddy/service/src/connectors/mcp/registerMcpConnectorRpc.ts index 84424a0e2..5b4f15c6d 100644 --- a/apps/buddy/service/src/connectors/mcp/registerMcpConnectorRpc.ts +++ b/apps/buddy/service/src/connectors/mcp/registerMcpConnectorRpc.ts @@ -58,6 +58,7 @@ export function registerMcpConnectorRpc( function toPublicConnector(record: McpServerRecord, runtime: ConnectorRuntimeState) { const common = { + ...(record.managed ? { managed: record.managed } : {}), runtime, credentialConfigured: record.credentialRef !== null, enabled: record.enabled, diff --git a/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts new file mode 100644 index 000000000..8de2b3e94 --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts @@ -0,0 +1,303 @@ +import type { BuddyCatalog, LocalBuddy } from '../../../shared/buddies/buddyApi' +import type { BuddySettings } from '../../../shared/buddies/buddySettings' +import type { ConnectorToolSummary } from '../../../shared/connectors/connectorState' +import type { ExtensionCapabilityDescriptor, ExtensionCapabilityImpact } from '../../../shared/extensions/extensionCapabilities' +import type { ExtensionSubagentContribution } from '../../../shared/extensions/extensionSubagents' +import type { McpServerRecord } from '../storage/connectorRepository' +import type { ManagedCapabilityRecord, ManagedCapabilityRepository } from '../storage/managedCapabilityRepository' +import { DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings' +import { extensionCapabilityDescriptorSchema } from '../../../shared/extensions/extensionCapabilities' +import { skillIdentity } from '../skills/skillFiles' + +export interface CapabilityBuddies { + list: () => BuddyCatalog + get: (id: string) => LocalBuddy + installManagedNpc: (input: { id: string, name: string, description: string, settings: BuddySettings }) => LocalBuddy + refreshManaged: (id: string) => void + clearManagedConfiguration: (id: string) => void +} +export interface CapabilityConnectors { + list: () => readonly McpServerRecord[] + tools: (id: string) => ConnectorToolSummary[] + applyManaged: (source: ManagedCapabilityRecord) => Promise + refreshManaged: (id: string) => Promise + clearManagedConfiguration: (id: string) => Promise +} +export interface ExtensionCapabilitySyncOptions { + buddies: CapabilityBuddies + connectors: CapabilityConnectors + sources: ManagedCapabilityRepository + descriptors: () => Promise + report?: (event: { event: string, level: 'warn', errorCode: string, extensionId?: string, kind?: ManagedCapabilityRecord['kind'], contributionId?: string }) => void +} + +export class ExtensionCapabilitySync { + readonly #options: ExtensionCapabilitySyncOptions + #queue: Promise = Promise.resolve() + #epoch = 0 + #disposed = false + + constructor(options: ExtensionCapabilitySyncOptions) { + this.#options = options + } + + async schedule(): Promise { + const epoch = this.#epoch + try { + const descriptors = await this.#options.descriptors() + if (this.#disposed || epoch !== this.#epoch) + return + await this.#enqueue(async () => { + if (epoch === this.#epoch) + await this.#apply(descriptors) + }) + } + catch (error) { + if (!(error instanceof CapabilityApplicationError) && !this.#disposed && epoch === this.#epoch) + await this.#enqueue(() => this.#suspend()).catch(() => {}) + this.#options.report?.({ event: 'extensions.capabilities.reconcile_failed', level: 'warn', errorCode: errorCode(error) }) + } + } + + apply(descriptors: ExtensionCapabilityDescriptor[], clearExtensionId?: string, signal?: AbortSignal): Promise { + this.#epoch++ + return this.#enqueue(async () => { + signal?.throwIfAborted() + try { + await this.#apply(descriptors, clearExtensionId) + } + catch (error) { + if (!(error instanceof CapabilityApplicationError)) + await this.#suspend().catch(() => {}) + throw error + } + }) + } + + refreshAvailability(): Promise { + return this.#enqueue(async () => { + const failures: unknown[] = [] + this.#refreshAvailability(failures) + if (failures.length) + throw new CapabilityApplicationError(failures) + }) + } + + async impact(extensionId: string): Promise { + await this.#queue + const sources = this.#options.sources.list(extensionId) + const connectorIds = new Set(sources.filter(source => source.kind === 'mcp').map(source => source.id)) + const skillIds = new Set(sources.filter(source => source.kind === 'subagent').flatMap(source => (source.definition as ExtensionSubagentContribution).skills.map(id => skillIdentity(`plugin:${id}`)))) + const dependents = this.#options.buddies.list().buddies.filter((buddy) => { + if (buddy.managed || buddy.deletedAt || buddy.kind !== 'npc') + return false + const tools = buddy.settings.tools + return (tools.mode === 'selected' && tools.ids.some((id) => { + const [kind, connector] = JSON.parse(id) as string[] + return kind === 'mcp' && connectorIds.has(connector!) + })) || (buddy.settings.skills.mode === 'selected' && buddy.settings.skills.ids.some(id => skillIds.has(id))) || buddy.settings.requiredSkills.some(id => skillIds.has(id)) + }).map(({ id, name }) => ({ id, name })) + return { retained: sources.map(source => ({ kind: source.kind, name: source.definition.name })), dependents } + } + + async dispose(): Promise { + this.#disposed = true + this.#epoch++ + await this.#queue + } + + #enqueue(operation: () => Promise): Promise { + if (this.#disposed) + return Promise.reject(new Error('EXTENSION_SERVICE_UNAVAILABLE')) + const pending = this.#queue.then(operation) + this.#queue = pending.catch(() => {}) + return pending + } + + async #apply(input: ExtensionCapabilityDescriptor[], clearExtensionId?: string): Promise { + const descriptors = extensionCapabilityDescriptorSchema.array().parse(input) + validateDescriptors(descriptors) + if (clearExtensionId && descriptors.some(descriptor => descriptor.extensionId === clearExtensionId && descriptor.enabled)) + throw new CapabilityApplicationError([new Error('EXTENSION_STILL_ENABLED')]) + const { sources, buddies, connectors } = this.#options + const failures: unknown[] = [] + const failed = new Set() + const previous = new Map(sources.list().map(source => [identity(source), source])) + const current = new Map() + for (const descriptor of descriptors) { + for (const [kind, definitions] of [['mcp', descriptor.mcpConnectors], ['subagent', descriptor.subagents]] as const) { + for (const definition of definitions) { + const key = identity({ extensionId: descriptor.extensionId, kind, contributionId: definition.id }) + const before = previous.get(key) + const state = descriptor.enabled ? 'enabled' : 'disabled' + const definitionChanged = !before || before.revision !== descriptor.revision || JSON.stringify(before.definition) !== JSON.stringify(definition) + const changed = definitionChanged || before?.state !== state || before.extensionName !== descriptor.extensionName + const source = sources.upsertSource({ extensionId: descriptor.extensionId, extensionName: descriptor.extensionName, revision: descriptor.revision, contributionId: definition.id, kind, definition, state: changed ? 'unavailable' : state }) + if (before?.state === 'removed' && kind === 'mcp') + sources.setUserEnabled(source.id, false) + current.set(key, { source, descriptor, changed }) + } + } + } + const removed = [...previous].filter(([key]) => !current.has(key)).map(([, source]) => source) + for (const source of removed) { + sources.setState(source.id, 'removed') + if (source.kind === 'mcp') + sources.setUserEnabled(source.id, false) + } + this.#refreshAvailability(failures, failed) + const attempt = async (source: ManagedCapabilityRecord, operation: () => void | Promise) => { + try { + await operation() + } + catch (error) { + failed.add(source.id) + this.#sourceFailed(source, error, failures) + try { + await this.#refresh(source) + } + catch (refreshError) { + failures.push(refreshError) + } + } + } + for (const source of removed) + await attempt(source, () => this.#refresh(source)) + for (const { source } of current.values()) { + if (source.kind === 'mcp') { + await attempt(source, () => connectors.applyManaged(sources.get(source.id)!)) + } + else { + await attempt(source, () => { + const definition = source.definition as ExtensionSubagentContribution + buddies.installManagedNpc({ id: source.id, name: definition.name, description: definition.description, settings: compileSettings(source.extensionId, definition, sources) }) + }) + } + } + if (clearExtensionId) { + for (const source of sources.list(clearExtensionId)) { + if (!failed.has(source.id)) + await attempt(source, () => source.kind === 'mcp' ? connectors.clearManagedConfiguration(source.id) : buddies.clearManagedConfiguration(source.id)) + } + } + for (const { source, descriptor, changed } of current.values()) { + if (failed.has(source.id)) + continue + await attempt(source, async () => { + sources.setState(source.id, descriptor.enabled ? 'enabled' : 'disabled') + if (changed) + await this.#refresh(source) + }) + } + this.#refreshAvailability(failures, failed) + if (failures.length) + throw new CapabilityApplicationError(failures) + } + + async #refresh(source: ManagedCapabilityRecord): Promise { + if (source.kind === 'mcp') + await this.#options.connectors.refreshManaged(source.id) + else if (this.#buddy(source.id)) + this.#options.buddies.refreshManaged(source.id) + } + + #sourceFailed(source: ManagedCapabilityRecord, error: unknown, failures: unknown[]): void { + failures.push(error) + const { sources, report } = this.#options + if (sources.get(source.id)?.state !== 'removed') + sources.setState(source.id, 'unavailable') + report?.({ event: 'extensions.capabilities.source_failed', level: 'warn', errorCode: errorCode(error), extensionId: source.extensionId, kind: source.kind, contributionId: source.contributionId }) + } + + async #suspend(): Promise { + const { sources } = this.#options + const affected = sources.list().filter(source => source.state !== 'removed') + sources.suspendAll() + const results = await Promise.allSettled(affected.map(source => this.#refresh(source))) + const failures = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []) + if (failures.length) + throw new CapabilityApplicationError(failures) + } + + #refreshAvailability(failures: unknown[], failed?: Set): void { + const { sources, buddies, connectors } = this.#options + const connections = new Map(connectors.list().map(record => [record.id, record])) + for (const source of sources.list()) { + if (source.kind !== 'subagent') + continue + try { + const definition = source.definition as ExtensionSubagentContribution + const missing = definition.tools.some((tool) => { + if (tool.source !== 'mcp') + return false + const connection = sources.find(source.extensionId, 'mcp', tool.connector) + if (!connection || !connections.get(connection.id)?.enabled) + return true + return !connectors.tools(connection.id).some(candidate => candidate.name === tool.name) + }) + const reason = missing ? 'dependency_unavailable' : null + if (source.unavailableReason !== reason) { + sources.setUnavailableReason(source.id, reason) + if (this.#buddy(source.id)) + buddies.refreshManaged(source.id) + } + } + catch (error) { + failed?.add(source.id) + this.#sourceFailed(source, error, failures) + } + } + } + + #buddy(id: string): LocalBuddy | null { + try { + return this.#options.buddies.get(id) + } + catch (error) { + if (errorCode(error) === 'BUDDY_NOT_FOUND') + return null + throw error + } + } +} + +class CapabilityApplicationError extends AggregateError { + readonly code = 'EXTENSION_CAPABILITY_FAILED' + + constructor(errors: unknown[]) { super(errors, 'EXTENSION_CAPABILITY_FAILED') } +} + +function compileSettings(extensionId: string, definition: ExtensionSubagentContribution, sources: ManagedCapabilityRepository): BuddySettings { + const ids = definition.tools.map(tool => tool.source === 'builtin' + ? JSON.stringify(['builtin', 'core', tool.name]) + : JSON.stringify(['mcp', sources.find(extensionId, 'mcp', tool.connector)!.id, tool.name])) + return { + ...structuredClone(DEFAULT_BUDDY_SETTINGS), + instructions: definition.instructions, + tools: ids.length ? { mode: 'selected', ids } : { mode: 'none' }, + skills: definition.skills.length ? { mode: 'selected', ids: definition.skills.map(id => skillIdentity(`plugin:${id}`)) } : { mode: 'none' }, + requiredSkills: definition.requiredSkills.map(id => skillIdentity(`plugin:${id}`)), + limits: { fileAccess: definition.fileAccess }, + } +} + +function validateDescriptors(descriptors: ExtensionCapabilityDescriptor[]): void { + if (new Set(descriptors.map(descriptor => descriptor.extensionId)).size !== descriptors.length) + throw new Error('EXTENSION_CAPABILITY_INVALID') + for (const descriptor of descriptors) { + const connections = new Set(descriptor.mcpConnectors.map(item => item.id)) + const subagents = new Set(descriptor.subagents.map(item => item.id)) + if (connections.size !== descriptor.mcpConnectors.length || subagents.size !== descriptor.subagents.length + || descriptor.subagents.some(agent => agent.tools.some(tool => tool.source === 'mcp' && !connections.has(tool.connector)) || agent.skills.some(id => !descriptor.skills.includes(id)))) { + throw new Error('EXTENSION_CAPABILITY_INVALID') + } + } +} + +function identity(source: Pick): string { + return JSON.stringify([source.extensionId, source.kind, source.contributionId]) +} + +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..82ef5a306 --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts @@ -0,0 +1,328 @@ +import type { ExtensionCapabilityDescriptor } from '../../../../shared/extensions/extensionCapabilities' +import type { ExtensionMcpConnectorContribution } from '../../../../shared/extensions/extensionMcpConnectors' +import type { McpServerRecord } from '../../storage/connectorRepository' +import { afterEach, describe, expect, it } from 'vitest' +import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings' +import { extensionCapabilityDescriptorSchema } from '../../../../shared/extensions/extensionCapabilities' +import { isManagedCapabilityEnabled } from '../../../../shared/extensions/managedCapability' +import { BuddiesService } from '../../buddies/BuddiesService' +import { createBuddyRepository } from '../../storage/buddyRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createManagedCapabilityRepository, toManagedCapabilitySource } from '../../storage/managedCapabilityRepository' +import { ExtensionCapabilitySync } from '../ExtensionCapabilitySync' + +const cleanups: (() => void)[] = [] +afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup())) + +function descriptor(patch: Partial = {}) { + return extensionCapabilityDescriptorSchema.parse({ + extensionId: 'tests.legal', + extensionName: 'Legal', + revision: 'a', + enabled: true, + mcpConnectors: [{ id: 'search', name: 'Search', transport: 'streamable-http', url: 'https://example.com/mcp', authentication: 'bearer' }], + subagents: [{ id: 'researcher', name: 'Researcher', instructions: 'Find original sources.', tools: [{ source: 'mcp', connector: 'search', name: 'search_statutes' }], skills: ['tests.legal.research'], requiredSkills: ['tests.legal.research'], fileAccess: 'inherit' }], + skills: ['tests.legal.research'], + ...patch, + }) +} + +function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const sources = createManagedCapabilityRepository(database) + const buddies = new BuddiesService(createBuddyRepository(database)) + const records = new Map() + const catalogs = new Map() + let descriptors = [descriptor()] + let readError: Error | undefined + let failInstall = false + let failNpc = false + const clearFailures = new Set() + const refreshFailures = new Set() + const sync = new ExtensionCapabilitySync({ + sources, + buddies: { + list: () => buddies.list(), + get: id => buddies.get(id), + installManagedNpc(input) { + if (failNpc) { + failNpc = false + throw new Error('NPC_INSTALL_FAILED') + } + return buddies.installManagedNpc(input) + }, + refreshManaged: id => buddies.refreshManaged(id), + clearManagedConfiguration: id => buddies.clearManagedConfiguration(id), + }, + descriptors: async () => { + if (readError) + throw readError + return descriptors + }, + connectors: { + list: () => [...records.values()].map((record) => { + const source = sources.get(record.id) + return source ? { ...record, enabled: isManagedCapabilityEnabled(source), managed: toManagedCapabilitySource(source) } : record + }), + tools: id => (catalogs.get(id) ?? []).map(name => ({ name, title: name, description: '', readOnly: true })), + async applyManaged(source) { + if (failInstall) { + failInstall = false + throw new Error('MCP_INSTALL_FAILED') + } + const definition = source.definition as ExtensionMcpConnectorContribution + const old = records.get(source.id) + records.set(source.id, { id: source.id, name: definition.name, transport: 'streamable-http', url: definition.url, command: null, args: null, cwd: null, credentialRef: old?.credentialRef ?? null, toolNamespace: source.id.replaceAll('-', '_').slice(0, 32), toolExposure: old?.toolExposure ?? 'deferred', enabled: false, executionConfirmedAt: null, createdAt: old?.createdAt ?? 'now', updatedAt: 'now' }) + }, + async refreshManaged(id) { + if (refreshFailures.has(id)) + throw new Error('MCP_RESET_FAILED') + }, + async clearManagedConfiguration(id) { + if (clearFailures.has(id)) + throw new Error('MCP_SECRET_DELETE_FAILED') + sources.clearConfiguration(id) + const record = records.get(id) + if (record) + records.set(id, { ...record, credentialRef: null }) + }, + }, + }) + cleanups.push(() => { + buddies.dispose() + database.close() + }) + const source = (kind: 'mcp' | 'subagent', extensionId = 'tests.legal') => sources.list(extensionId).find(item => item.kind === kind)! + return { + database, + sources, + buddies, + records, + catalogs, + clearFailures, + refreshFailures, + sync, + source, + failNextInstall: () => { failInstall = true }, + failNextNpcInstall: () => { failNpc = true }, + setDescriptors: (next: ExtensionCapabilityDescriptor[]) => { descriptors = next }, + failRead: () => { readError = new Error('offline') }, + } +} + +describe('managed extension capabilities', () => { + it('allocates independent identities and scopes tools and Skills without changing actor teams', async () => { + const f = fixture() + const actor = await f.buddies.create({ name: 'Selected actor', kind: 'actor' }) + const definition = descriptor() + await f.sync.apply([definition]) + const connector = f.source('mcp') + const npc = f.buddies.get(f.source('subagent').id) + expect(connector.id).not.toBe('search') + expect(connector.userEnabled).toBe(false) + expect(npc.managed).toMatchObject({ extensionId: 'tests.legal', unavailableReason: 'dependency_unavailable' }) + expect(npc.settings.tools).toEqual({ mode: 'selected', ids: [JSON.stringify(['mcp', connector.id, 'search_statutes'])] }) + expect(npc.settings.skills).toMatchObject({ mode: 'selected' }) + expect(npc.settings.requiredSkills).toHaveLength(1) + expect(f.buddies.get(actor.id)).toEqual(actor) + expect(f.buddies.get('default').settings.npcIds).not.toContain(npc.id) + }) + + it('does not collide with another plugin or an existing user connector', async () => { + const f = fixture() + f.records.set('search', { id: 'search', name: 'User', transport: 'streamable-http', url: 'https://private.example.com/mcp', credentialRef: 'user-secret', command: null, args: null, cwd: null, toolNamespace: 'user', toolExposure: 'direct', enabled: true, executionConfirmedAt: null, createdAt: 'then', updatedAt: 'then' }) + await f.sync.apply([descriptor(), descriptor({ extensionId: 'tests.other' })]) + expect(f.source('mcp').id).not.toBe(f.source('mcp', 'tests.other').id) + expect(f.records.get('search')).toMatchObject({ enabled: true, credentialRef: 'user-secret', url: 'https://private.example.com/mcp' }) + expect(f.sources.get('search')).toBeNull() + }) + + it('keeps user model, switch and unbinding across package updates and startup refreshes', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + let npc = f.buddies.get(f.source('subagent').id) + const model = { providerId: 'provider', modelId: 'model', reasoning: null, serviceTier: null } + await f.buddies.update({ buddyId: npc.id, expectedRevision: npc.revision, enabled: false, settings: buddySettingsSchema.parse({ ...npc.settings, model }) }) + const actor = f.buddies.get('default') + await f.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds: actor.settings.npcIds.filter(id => id !== npc.id) }) }) + const update = descriptor({ revision: 'b' }) + update.subagents[0]!.instructions = 'Updated original rules.' + await f.sync.apply([update]) + npc = f.buddies.get(npc.id) + expect(npc.settings.model).toMatchObject(model) + expect(npc.managed?.userEnabled).toBe(false) + expect(npc.settings.instructions).toBe('Updated original rules.') + expect(f.buddies.get('default').settings.npcIds).not.toContain(npc.id) + f.sources.suspendAll() + await f.sync.apply([update]) + expect(f.buddies.get('default').settings.npcIds).not.toContain(npc.id) + }) + + it('does not reactivate a user-disabled connector when a plugin is enabled', async () => { + const f = fixture() + await f.sync.apply([descriptor({ enabled: false })]) + const npc = f.buddies.get(f.source('subagent').id) + expect(npc.enabled).toBe(false) + f.sources.setUserEnabled(f.source('mcp').id, true) + f.catalogs.set(f.source('mcp').id, ['search_statutes']) + await f.sync.apply([descriptor()]) + expect(f.buddies.get(npc.id).enabled).toBe(true) + await f.sync.apply([descriptor({ enabled: false })]) + expect(f.source('mcp').userEnabled).toBe(true) + expect(f.buddies.get(npc.id).enabled).toBe(false) + f.sources.setUserEnabled(f.source('mcp').id, false) + await f.sync.apply([descriptor()]) + expect(f.source('mcp').userEnabled).toBe(false) + expect(f.buddies.get(npc.id).enabled).toBe(false) + }) + + it('retains configuration and identities on uninstall, with explicit cleanup and safe reinstall', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + const connector = f.source('mcp') + const npc = f.buddies.get(f.source('subagent').id) + f.records.set(connector.id, { ...f.records.get(connector.id)!, credentialRef: 'retained-secret' }) + f.sources.setUserEnabled(connector.id, true) + await f.sync.apply([]) + expect(f.source('mcp')).toMatchObject({ id: connector.id, state: 'removed', userEnabled: false }) + expect(f.records.get(connector.id)?.credentialRef).toBe('retained-secret') + expect(f.buddies.get(npc.id).deletedAt).toBeNull() + expect(f.buddies.get(npc.id).enabled).toBe(false) + await f.sync.apply([descriptor()]) + expect(f.source('mcp')).toMatchObject({ id: connector.id, userEnabled: false }) + await f.sync.apply([descriptor({ enabled: false })], 'tests.legal') + expect(f.records.get(connector.id)?.credentialRef).toBeNull() + expect(f.buddies.get(npc.id).managed?.userEnabled).toBe(false) + }) + + it('reports retained configuration and custom NPC dependencies without digest guesses', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + const sourceNpc = f.buddies.get(f.source('subagent').id) + const copy = await f.buddies.duplicate({ buddyId: sourceNpc.id, expectedRevision: sourceNpc.revision }) + expect(await f.sync.impact('tests.legal')).toMatchObject({ retained: [{ kind: 'mcp', name: 'Search' }, { kind: 'subagent', name: 'Researcher' }], dependents: [{ id: copy.id }] }) + await f.sync.apply([]) + expect(f.buddies.get(copy.id).deletedAt).toBeNull() + }) + + it('suspends but never removes records when the authoritative source cannot be read', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + f.failRead() + await f.sync.schedule() + expect(f.sources.list().every(source => source.state === 'unavailable')).toBe(true) + expect(f.records.size).toBe(1) + expect(f.buddies.get(f.source('subagent').id).deletedAt).toBeNull() + }) + + it('reapplies the NPC definition after an interrupted update instead of treating source metadata as success', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + const id = f.source('subagent').id + const update = descriptor({ revision: 'b' }) + update.subagents[0]!.tools = [] + update.subagents[0]!.fileAccess = 'read_only' + update.subagents[0]!.instructions = 'Read only after update.' + f.failNextNpcInstall() + await expect(f.sync.apply([update])).rejects.toMatchObject({ errors: [expect.objectContaining({ message: 'NPC_INSTALL_FAILED' })] }) + expect(f.source('subagent')).toMatchObject({ revision: 'b', state: 'unavailable' }) + expect(f.buddies.get(id).settings.limits.fileAccess).toBe('inherit') + await f.sync.apply([update]) + expect(f.buddies.get(id)).toMatchObject({ enabled: true, settings: { instructions: 'Read only after update.', limits: { fileAccess: 'read_only' }, tools: { mode: 'none' } } }) + }) + + it.each(['install', 'reset'] as const)('isolates MCP %s failures from independent NPCs and connections and recovers on retry', async (failure) => { + const f = fixture() + const main = descriptor() + main.subagents.push({ id: 'reader', name: 'Reader', description: '', instructions: 'Local only.', fileAccess: 'read_only', tools: [], skills: [], requiredSkills: [] }) + const healthy = descriptor({ extensionId: 'tests.healthy', mcpConnectors: [], skills: [], subagents: [main.subagents[1]!] }) + const connectionOnly = descriptor({ extensionId: 'tests.connection', subagents: [], skills: [] }) + await f.sync.apply([main, healthy, connectionOnly]) + const connector = f.source('mcp') + const otherConnector = f.source('mcp', 'tests.connection') + f.sources.setUserEnabled(connector.id, true) + f.sources.setUserEnabled(otherConnector.id, true) + f.catalogs.set(connector.id, ['search_statutes']) + await f.sync.refreshAvailability() + const dependent = f.sources.find('tests.legal', 'subagent', 'researcher')! + const independent = f.sources.find('tests.legal', 'subagent', 'reader')! + const healthyNpc = f.source('subagent', 'tests.healthy') + expect(f.buddies.get(dependent.id).enabled).toBe(true) + const actor = f.buddies.get('default') + const update = { ...main, revision: 'b' } + update.subagents[1]!.instructions = 'Updated local rules.' + if (failure === 'install') + f.failNextInstall() + else + f.refreshFailures.add(connector.id) + await expect(f.sync.apply([update, healthy, connectionOnly])).rejects.toMatchObject({ code: 'EXTENSION_CAPABILITY_FAILED' }) + expect(f.sources.get(connector.id)?.state).toBe('unavailable') + expect(f.buddies.get(dependent.id)).toMatchObject({ enabled: false, managed: { unavailableReason: 'dependency_unavailable' } }) + expect(f.buddies.get(independent.id)).toMatchObject({ enabled: true, settings: { instructions: 'Updated local rules.' } }) + expect(f.buddies.get(healthyNpc.id).enabled).toBe(true) + expect(isManagedCapabilityEnabled(f.sources.get(otherConnector.id)!)).toBe(true) + expect(f.buddies.get('default')).toEqual(actor) + f.refreshFailures.clear() + await f.sync.apply([update, healthy, connectionOnly]) + expect(f.buddies.get(dependent.id).enabled).toBe(true) + expect(f.sources.get(connector.id)?.state).toBe('enabled') + expect(f.buddies.get('default')).toEqual(actor) + }) + + it('keeps unrelated team membership and configuration when MCP cleanup fails, then retries without losing identity', async () => { + const f = fixture() + const healthy = descriptor({ extensionId: 'tests.healthy' }) + healthy.subagents[0]!.tools = [] + await f.sync.apply([descriptor(), healthy]) + const connector = f.source('mcp') + f.records.set(connector.id, { ...f.records.get(connector.id)!, credentialRef: 'retained-secret' }) + const healthyNpc = f.buddies.get(f.source('subagent', 'tests.healthy').id) + const current = f.buddies.get('default') + const actor = await f.buddies.update({ buddyId: current.id, expectedRevision: current.revision, settings: buddySettingsSchema.parse({ ...current.settings, npcIds: [healthyNpc.id] }) }) + f.clearFailures.add(connector.id) + await expect(f.sync.apply([descriptor({ enabled: false }), healthy], 'tests.legal')).rejects.toMatchObject({ errors: [expect.objectContaining({ message: 'MCP_SECRET_DELETE_FAILED' })] }) + expect(f.records.get(connector.id)?.credentialRef).toBe('retained-secret') + expect(f.sources.get(connector.id)?.state).toBe('unavailable') + expect(f.buddies.get(healthyNpc.id)).toEqual(healthyNpc) + expect(f.buddies.get('default')).toEqual(actor) + f.clearFailures.clear() + await f.sync.apply([descriptor({ enabled: false }), healthy], 'tests.legal') + expect(f.records.get(connector.id)?.credentialRef).toBeNull() + expect(f.sources.get(connector.id)?.state).toBe('disabled') + expect(f.buddies.get('default')).toEqual(actor) + }) + + it('revokes all effective states on authority loss even if one connection cannot finish closing', async () => { + const f = fixture() + const healthy = descriptor({ extensionId: 'tests.healthy' }) + healthy.subagents[0]!.tools = [] + await f.sync.apply([descriptor(), healthy]) + const connector = f.source('mcp') + f.sources.setUserEnabled(connector.id, true) + f.refreshFailures.add(connector.id) + f.failRead() + await f.sync.schedule() + expect(f.sources.list().every(source => source.state === 'unavailable')).toBe(true) + expect(f.buddies.get(f.source('subagent', 'tests.healthy').id).enabled).toBe(false) + expect(f.sources.get(connector.id)?.userEnabled).toBe(true) + expect(f.records.size).toBe(2) + }) + + it('leaves an accepted snapshot intact when a queued apply was cancelled before admission', async () => { + const f = fixture() + await f.sync.apply([descriptor()]) + const before = f.sources.list() + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect(f.sync.apply([], undefined, controller.signal)).rejects.toThrow('cancelled') + expect(f.sources.list()).toEqual(before) + }) + + it('ignores an older discovery read after an explicit lifecycle snapshot', async () => { + const f = fixture() + const stale = f.sync.schedule() + await f.sync.apply([descriptor({ enabled: false })]) + await stale + expect(f.source('mcp').state).toBe('disabled') + }) +}) diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index f5bc46e00..3c1b7c24a 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -29,6 +29,7 @@ export type BuddyServiceErrorCode | 'BUDDY_LEADER_PROTECTED' | 'BUDDY_INVALID_NPC_BINDING' | 'BUDDY_CAPABILITY_UNAVAILABLE' + | 'BUDDY_MANAGED_PROTECTED' | 'BUDDY_BUILTIN_PROTECTED' | 'BUDDY_DEFAULT_PROTECTED' | 'BUDDY_RESET_UNAVAILABLE' diff --git a/apps/buddy/service/src/storage/__tests__/buddyRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/buddyRepository.spec.ts index 622128637..5de19ecdc 100644 --- a/apps/buddy/service/src/storage/__tests__/buddyRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/buddyRepository.spec.ts @@ -70,6 +70,8 @@ it('protects default eligibility, NPC bindings and deleted definition history', npc = repository.delete({ buddyId: npc.id, expectedRevision: npc.revision }) expect(repository.get(npc.id)).toEqual(npc) expect(repository.get(actor.id).settings.npcIds).toEqual([npc.id]) - expect(() => repository.update({ buddyId: actor.id, expectedRevision: bound.revision, settings: { ...buddySettingsSchema.parse(bound.settings), npcIds: [npc.id] } })).toThrow(expect.objectContaining({ code: 'BUDDY_INVALID_NPC_BINDING' })) + expect(repository.update({ buddyId: actor.id, expectedRevision: bound.revision, settings: { ...buddySettingsSchema.parse(bound.settings), npcIds: [npc.id] } })).toEqual(bound) + const otherActor = repository.create({ name: 'New coordinator' }) + expect(() => repository.update({ buddyId: otherActor.id, expectedRevision: otherActor.revision, settings: { ...DEFAULT_BUDDY_SETTINGS, npcIds: [npc.id], npcTeamEnabled: false } })).toThrow(expect.objectContaining({ code: 'BUDDY_INVALID_NPC_BINDING' })) expect(repository.update({ buddyId: actor.id, expectedRevision: bound.revision, settings: { ...DEFAULT_BUDDY_SETTINGS, npcIds: [] } }).settings.npcIds).toEqual([]) }) diff --git a/apps/buddy/service/src/storage/__tests__/executionRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/executionRepository.spec.ts index 38334f512..350b0f9f3 100644 --- a/apps/buddy/service/src/storage/__tests__/executionRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/executionRepository.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings' +import { managedBuddyFixture } from '../../buddies/__tests__/managedBuddyFixture' import { hashChildAdmissionRequest } from '../executionRepository' import { createTaskMarkRepository } from '../taskMarkRepository' import { createExecutionStorageFixture, executionNow } from './executionFixture' @@ -17,6 +18,43 @@ function rehash[0]>(input } describe('execution admission and fences', () => { + it.each(['disabled', 'removed', 'unavailable', 'dependency', 'user'] as const)('rejects %s managed targets in the admission transaction and preserves accepted snapshots', (reason) => { + const f = fixture() + const managed = managedBuddyFixture(f.database) + const npc = f.buddies.upsertManagedNpc(managed.input) + const actor = f.buddies.get('default') + f.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds: [npc.id] }) }) + f.runs.create({ ...f.runs.findById('root')!, id: 'managed-root', rootRunId: 'managed-root', actorSnapshot: f.snapshot('default') }) + const accepted = f.child('accepted-managed', npc.id, 'managed-root') + accepted.run.executionProfile = 'read_only' + f.executions.admitChild(rehash(accepted)) + const pending = f.child('rejected-managed', npc.id, 'managed-root') + pending.run.executionProfile = 'read_only' + if (reason === 'dependency') + managed.sources.setUnavailableReason(npc.id, 'dependency_unavailable') + else if (reason === 'user') + managed.sources.setUserEnabled(npc.id, false) + else + managed.sources.setState(npc.id, reason) + expect(() => f.executions.admitChild(rehash(pending))).toThrow(expect.objectContaining({ code: 'unauthorized' })) + expect(f.runs.findById('rejected-managed')).toBeNull() + expect(f.runs.findById('accepted-managed')?.actorSnapshot).toEqual(accepted.run.actorSnapshot) + expect(f.executions.admitChild(rehash(accepted)).created).toBe(false) + expect(f.executions.claimWorking(f.fence('accepted-managed')).phase).toBe('working') + const partner = f.child('actor-still-works', f.actor.id, 'managed-root') + const staleNpc = f.buddies.get(f.npc.id) + f.buddies.update({ buddyId: staleNpc.id, expectedRevision: staleNpc.revision, enabled: false }) + expect(f.executions.admitChild(partner).execution.run.actorSnapshot?.settings.npcIds).toEqual([]) + }) + + it('keeps the captured NPC scope narrowed when a previously unavailable binding becomes available', () => { + const f = fixture() + const request = f.child('narrowed-actor', f.actor.id) + request.run.actorSnapshot.settings.npcIds = [] + expect(f.executions.admitChild(rehash(request)).execution.run.actorSnapshot?.settings.npcIds).toEqual([]) + expect(f.buddies.get(f.actor.id).settings.npcIds).toEqual([f.npc.id]) + }) + it('freezes a delegated actor model only when admission succeeds and leaves other members unchanged', () => { const f = fixture() const request = f.child('actor-child', f.actor.id) diff --git a/apps/buddy/service/src/storage/__tests__/managedCapabilityBackup.spec.ts b/apps/buddy/service/src/storage/__tests__/managedCapabilityBackup.spec.ts new file mode 100644 index 000000000..a3da615de --- /dev/null +++ b/apps/buddy/service/src/storage/__tests__/managedCapabilityBackup.spec.ts @@ -0,0 +1,147 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { BackupScope } from '../../../../shared/storage/backupScope' +import { afterEach, expect, it } from 'vitest' +import { mergeBackupDatabase, selectBackupDatabase } from '../backupDatabase' +import { createConversationRepository } from '../conversationRepository' +import { openBuddyDatabase } from '../database' +import { sanitizeRestoredDatabase } from '../restorePolicy' + +const databases: DatabaseSync[] = [] +const now = '2026-10-27T00:00:00.000Z' +afterEach(() => { + for (const database of databases.splice(0)) + database.close() +}) + +function database() { + const db = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(db) + return db +} + +function seed(db: DatabaseSync, suffix = '') { + db.prepare('INSERT INTO buddies (id, kind, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(`npc${suffix}`, 'npc', 'Managed NPC', now, now) + db.prepare('INSERT INTO mcp_servers (id, name, transport, url, credential_ref, trusted_at, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)') + .run(`mcp${suffix}`, 'Managed MCP', 'streamable-http', 'https://example.invalid/mcp', `credential${suffix}`, now, now, now) + const insert = db.prepare('INSERT INTO extension_capabilities (id, extension_id, extension_name, kind, contribution_id, revision, definition_json, state, user_enabled, credential_target) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + insert.run(`npc${suffix}`, 'test.plugin', 'Plugin', 'subagent', `npc${suffix}`, 'revision-1', JSON.stringify({ id: `npc${suffix}`, name: 'NPC', instructions: 'Review', fileAccess: 'read_only' }), 'enabled', 1, null) + insert.run(`mcp${suffix}`, 'test.plugin', 'Plugin', 'mcp', `mcp${suffix}`, 'revision-1', JSON.stringify({ id: `mcp${suffix}`, name: 'MCP', transport: 'streamable-http', url: 'https://example.invalid/mcp', authentication: 'bearer' }), 'enabled', 1, 'https://example.invalid/mcp') +} + +function sourceRows(db: DatabaseSync) { + return db.prepare('SELECT * FROM extension_capabilities ORDER BY id').all() +} + +it.each<{ scopes: BackupScope[], ids: string[] }>([ + { scopes: ['buddies'], ids: ['npc'] }, + { scopes: ['connections'], ids: ['mcp'] }, + { scopes: ['plugins'], ids: [] }, + { scopes: ['buddies', 'connections'], ids: ['mcp', 'npc'] }, +])('backs up only sources belonging to the selected owners: $scopes', ({ scopes, ids }) => { + const db = database() + seed(db) + const before = sourceRows(db) + selectBackupDatabase(db, scopes) + expect(sourceRows(db)).toEqual(before.filter(row => ids.includes(String(row.id)))) + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) + +it.each([false, true])('retains task buddy source closure without taking over an existing unselected buddy (existing=%s)', (existing) => { + const source = database() + seed(source) + source.prepare('UPDATE buddies SET settings_json = json_set(settings_json, \'$.npcIds\', json(\'["npc"]\')) WHERE id = \'default\'').run() + createConversationRepository(source).create({ id: 'task', branchId: 'branch', title: 'Task', spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: now }) + sanitizeRestoredDatabase(source) + selectBackupDatabase(source, ['tasks']) + expect(sourceRows(source)).toMatchObject([{ id: 'npc', state: 'unavailable' }]) + expect(source.prepare('SELECT * FROM mcp_servers').all()).toEqual([]) + + const target = database() + if (existing) + target.prepare('INSERT INTO buddies (id, kind, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run('npc', 'npc', 'Current user NPC', now, now) + mergeBackupDatabase(target, source, ['tasks']) + if (existing) { + expect(target.prepare('SELECT name FROM buddies WHERE id = ?').get('npc')).toEqual({ name: 'Current user NPC' }) + expect(sourceRows(target)).toEqual([]) + } + else { + expect(target.prepare('SELECT name FROM buddies WHERE id = ?').get('npc')).toEqual({ name: 'Managed NPC' }) + expect(sourceRows(target)).toEqual(sourceRows(source)) + } +}) + +it.each(['buddies', 'connections'] as const)('imports managed owners and sources together into another profile: %s', (scope) => { + const source = database() + seed(source) + sanitizeRestoredDatabase(source) + selectBackupDatabase(source, [scope]) + const target = database() + const id = scope === 'buddies' ? 'npc' : 'mcp' + const table = scope === 'buddies' ? 'buddies' : 'mcp_servers' + for (let attempt = 0; attempt < 2; attempt++) { + mergeBackupDatabase(target, source, [scope]) + expect(target.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id)).toEqual(source.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id)) + expect(sourceRows(target)).toEqual(sourceRows(source)) + } +}) + +it.each(['buddies', 'connections'] as const)('restores stable source identities and disabled runtime state without altering unselected records: %s', (scope) => { + const source = database() + seed(source) + const original = sourceRows(source) + sanitizeRestoredDatabase(source) + selectBackupDatabase(source, [scope]) + const target = database() + seed(target) + seed(target, '-unselected') + target.prepare('UPDATE extension_capabilities SET revision = \'current-revision\'').run() + const before = sourceRows(target) + const id = scope === 'buddies' ? 'npc' : 'mcp' + const expected = original.find(row => row.id === id)! + for (let attempt = 0; attempt < 2; attempt++) { + mergeBackupDatabase(target, source, [scope]) + expect(target.prepare('SELECT * FROM extension_capabilities WHERE id = ?').get(id)).toEqual({ ...expected, state: 'unavailable', unavailable_reason: scope === 'connections' ? null : 'dependency_unavailable', user_enabled: scope === 'connections' ? 0 : expected.user_enabled }) + expect(sourceRows(target).filter(row => row.id !== id)).toEqual(before.filter(row => row.id !== id)) + expect(target.prepare('SELECT enabled, trusted_at, credential_ref FROM mcp_servers WHERE id = ?').get('mcp')).toEqual(scope === 'connections' + ? { enabled: 0, trusted_at: null, credential_ref: null } + : { enabled: 1, trusted_at: now, credential_ref: 'credential' }) + expect(target.prepare('SELECT enabled, trusted_at, credential_ref FROM mcp_servers WHERE id = ?').get('mcp-unselected')).toEqual({ enabled: 1, trusted_at: now, credential_ref: 'credential-unselected' }) + } +}) + +it.each([false, true])('preserves connector credentials only through the existing optional credential policy (preserve=%s)', (preserve) => { + const db = database() + seed(db) + sanitizeRestoredDatabase(db, preserve) + expect(db.prepare('SELECT enabled, trusted_at, credential_ref FROM mcp_servers').get()).toEqual({ enabled: 0, trusted_at: null, credential_ref: preserve ? 'credential' : null }) + expect(sourceRows(db)).toMatchObject([ + { id: 'mcp', state: 'unavailable', user_enabled: 0, unavailable_reason: null, credential_target: 'https://example.invalid/mcp' }, + { id: 'npc', state: 'unavailable', user_enabled: 1, unavailable_reason: 'dependency_unavailable' }, + ]) +}) + +it.each(['incoming-user', 'current-user', 'different-source', 'different-id'] as const)('rejects source ownership conflicts atomically: %s', (conflict) => { + const source = database() + const target = database() + seed(source) + seed(target) + if (conflict === 'incoming-user') + source.prepare('DELETE FROM extension_capabilities WHERE id = \'npc\'').run() + if (conflict === 'current-user') + target.prepare('DELETE FROM extension_capabilities WHERE id = \'npc\'').run() + if (conflict === 'different-source') + target.prepare('UPDATE extension_capabilities SET extension_id = \'other.plugin\' WHERE id = \'npc\'').run() + if (conflict === 'different-id') { + target.prepare('UPDATE buddies SET id = \'other-npc\' WHERE id = \'npc\'').run() + target.prepare('UPDATE extension_capabilities SET id = \'other-npc\' WHERE id = \'npc\'').run() + } + source.prepare('UPDATE buddies SET name = \'Incoming change\' WHERE id = \'default\'').run() + sanitizeRestoredDatabase(source) + selectBackupDatabase(source, ['buddies']) + const sourcesBefore = sourceRows(target) + const buddiesBefore = target.prepare('SELECT * FROM buddies ORDER BY id').all() + expect(() => mergeBackupDatabase(target, source, ['buddies'])).toThrow('SNAPSHOT_DEPENDENCY_CONFLICT') + expect(sourceRows(target)).toEqual(sourcesBefore) + expect(target.prepare('SELECT * FROM buddies ORDER BY id').all()).toEqual(buddiesBefore) + expect(target.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) diff --git a/apps/buddy/service/src/storage/__tests__/schema.spec.ts b/apps/buddy/service/src/storage/__tests__/schema.spec.ts index 93b1e87fc..c86f31b9f 100644 --- a/apps/buddy/service/src/storage/__tests__/schema.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/schema.spec.ts @@ -83,6 +83,42 @@ function seedRun( } describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { + it('adds managed capability sources to schema 35 without changing user definitions or history', () => { + const directory = mkdtempSync(join(tmpdir(), 'buddy-capability-migration-')) + directories.push(directory) + const databasePath = join(directory, 'buddy.sqlite3') + const previous = openMigrationFixtureDatabase(databasePath) + for (const migration of BUDDY_SCHEMA_MIGRATIONS.filter(migration => migration.version <= 35)) + previous.exec(migration.sql) + seedRun(previous) + previous.exec(` + INSERT INTO buddies (id, kind, name, description, enabled, created_at, updated_at) + VALUES ('user-npc', 'npc', 'User NPC', 'Keep user instructions', 0, '2026-10-27', '2026-10-27'); + INSERT INTO mcp_servers (id, name, transport, url, credential_ref, trusted_at, enabled, created_at, updated_at) + VALUES ('user-mcp', 'User MCP', 'streamable-http', 'https://example.invalid/mcp', 'synthetic-ref', '2026-10-27', 1, '2026-10-27', '2026-10-27'); + PRAGMA user_version = 35; + `) + const objects = previous.prepare('SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE \'sqlite_%\' ORDER BY name').all() + const rows = objects.filter(row => row.type === 'table').map(row => ({ + name: String(row.name), + data: previous.prepare(`SELECT * FROM "${String(row.name)}" ORDER BY rowid`).all(), + })) + previous.close() + + for (let attempt = 0; attempt < 2; attempt++) { + const migrated = openBuddyDatabase({ databasePath }) + expect(migrated.prepare('PRAGMA user_version').get()).toEqual({ user_version: BUDDY_SCHEMA_VERSION }) + expect(BUDDY_SCHEMA_VERSION).toBeGreaterThanOrEqual(36) + expect(migrated.prepare('SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE \'sqlite_%\' AND name != \'extension_capabilities\' ORDER BY name').all()).toEqual(objects) + for (const row of rows) + expect(migrated.prepare(`SELECT * FROM "${row.name}" ORDER BY rowid`).all(), row.name).toEqual(row.data) + expect(migrated.prepare('SELECT * FROM extension_capabilities').all()).toEqual([]) + expect(migrated.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + expect(migrated.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + migrated.close() + } + }) + it('upgrades schema 34 without rewriting queued input or historical data', () => { const directory = mkdtempSync(join(tmpdir(), 'buddy-input-delivery-migration-')) directories.push(directory) diff --git a/apps/buddy/service/src/storage/__tests__/selectiveBackup.spec.ts b/apps/buddy/service/src/storage/__tests__/selectiveBackup.spec.ts index 9b8d0471f..8a5dd10ee 100644 --- a/apps/buddy/service/src/storage/__tests__/selectiveBackup.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/selectiveBackup.spec.ts @@ -1,6 +1,6 @@ import type { BackupScope } from '../../../../shared/storage/backupScope' import { Buffer } from 'node:buffer' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -98,6 +98,72 @@ it('restores sound settings and managed bytes without replacing current assets, } }) +it.each([ + { scope: 'buddies' as const, broad: false }, + { scope: 'connections' as const, broad: false }, + { scope: 'buddies' as const, broad: true }, + { scope: 'connections' as const, broad: true }, +])('round-trips managed $scope independently of plugin files (broad snapshot=$broad)', async ({ scope, broad }) => { + const f = await fixture() + let db = f.database() + db.prepare('INSERT INTO buddies (id, kind, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run('managed-npc', 'npc', 'Managed NPC', now, now) + db.prepare('INSERT INTO mcp_servers (id, name, transport, url, credential_ref, trusted_at, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)') + .run('managed-mcp', 'Managed MCP', 'streamable-http', 'https://original.invalid/mcp', 'original-ref', now, now, now) + const source = db.prepare('INSERT INTO extension_capabilities (id, extension_id, extension_name, kind, contribution_id, revision, definition_json, state, user_enabled, credential_target) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + source.run('managed-npc', 'test.plugin', 'Plugin', 'subagent', 'reviewer', 'old-revision', JSON.stringify({ id: 'reviewer', name: 'Review', instructions: 'Review', fileAccess: 'read_only' }), 'enabled', 1, null) + source.run('managed-mcp', 'test.plugin', 'Plugin', 'mcp', 'tools', 'old-revision', JSON.stringify({ id: 'tools', name: 'Tools', transport: 'streamable-http', url: 'https://original.invalid/mcp', authentication: 'bearer' }), 'enabled', 1, 'https://original.invalid/mcp') + const originalSources = db.prepare('SELECT * FROM extension_capabilities ORDER BY id').all() + db.close() + const pluginPath = join(f.root, 'buddy/extensions/data/test.plugin/state.json') + await writeStorageJson(pluginPath, { value: 'old' }) + const snapshot = await f.snapshot(broad ? ['buddies', 'connections', 'plugins'] : [scope]) + expect(snapshot.files.some(file => file.path === 'buddy/extensions/data/test.plugin/state.json')).toBe(broad) + const extracted = join(f.directory, 'extracted') + await extractSnapshotFiles(snapshotDirectory(f.root, snapshot.id), extracted, snapshot, { signal, report, privateDirectoriesExecutable }) + const saved = openBuddyDatabase({ databasePath: join(extracted, 'buddy/buddy.sqlite3') }) + expect(saved.prepare('SELECT id FROM extension_capabilities ORDER BY id').all()).toEqual(broad ? [{ id: 'managed-mcp' }, { id: 'managed-npc' }] : [{ id: scope === 'buddies' ? 'managed-npc' : 'managed-mcp' }]) + saved.close() + + db = f.database() + db.prepare('UPDATE buddies SET name = \'Newer NPC\' WHERE id = \'managed-npc\'').run() + db.prepare('UPDATE mcp_servers SET url = \'https://current.invalid/mcp\', credential_ref = \'current-ref\' WHERE id = \'managed-mcp\'').run() + db.prepare('UPDATE extension_capabilities SET revision = \'current-revision\', credential_target = CASE WHEN kind = \'mcp\' THEN \'https://current.invalid/mcp\' ELSE NULL END').run() + db.prepare('INSERT INTO mcp_servers (id, name, transport, url, credential_ref, trusted_at, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)') + .run('unselected-mcp', 'Unselected MCP', 'streamable-http', 'https://unselected.invalid/mcp', 'unselected-ref', now, now, now) + const unselectedSources = db.prepare('SELECT * FROM extension_capabilities WHERE kind != ?').all(scope === 'buddies' ? 'subagent' : 'mcp') + const unselectedMcp = db.prepare('SELECT * FROM mcp_servers WHERE id = \'unselected-mcp\'').get() + db.close() + const secretPath = (ref: string) => join(f.root, 'buddy/secrets/connectors', `${createHash('sha256').update(ref).digest('hex')}.credential`) + await mkdir(join(f.root, 'buddy/secrets/connectors'), { recursive: true }) + await writeFile(secretPath('current-ref'), 'synthetic-current-target-credential') + await writeFile(secretPath('unselected-ref'), 'synthetic-unselected-credential') + await writeStorageJson(pluginPath, { value: 'current' }) + await f.store.request('restore', f.root, [], snapshot.id, [scope]) + await f.run() + + db = f.database() + const selected = scope === 'buddies' ? 'managed-npc' : 'managed-mcp' + expect(db.prepare('SELECT * FROM extension_capabilities WHERE id = ?').get(selected)).toEqual({ + ...originalSources.find(row => row.id === selected), + state: 'unavailable', + unavailable_reason: scope === 'connections' ? null : 'dependency_unavailable', + user_enabled: scope === 'connections' ? 0 : 1, + }) + expect(db.prepare('SELECT * FROM extension_capabilities WHERE kind != ?').all(scope === 'buddies' ? 'subagent' : 'mcp')).toEqual(unselectedSources) + expect(db.prepare('SELECT * FROM mcp_servers WHERE id = \'unselected-mcp\'').get()).toEqual(unselectedMcp) + expect(db.prepare('SELECT name FROM buddies WHERE id = \'managed-npc\'').get()).toEqual({ name: scope === 'buddies' ? 'Managed NPC' : 'Newer NPC' }) + expect(db.prepare('SELECT enabled, url, credential_ref FROM mcp_servers WHERE id = \'managed-mcp\'').get()).toEqual(scope === 'connections' + ? { enabled: 0, url: 'https://original.invalid/mcp', credential_ref: null } + : { enabled: 1, url: 'https://current.invalid/mcp', credential_ref: 'current-ref' }) + db.close() + expect(JSON.parse(await readFile(pluginPath, 'utf8'))).toEqual({ value: 'current' }) + expect(await readFile(secretPath('unselected-ref'), 'utf8')).toBe('synthetic-unselected-credential') + if (scope === 'connections') + await expect(readFile(secretPath('current-ref'))).rejects.toMatchObject({ code: 'ENOENT' }) + else + expect(await readFile(secretPath('current-ref'), 'utf8')).toBe('synthetic-current-target-credential') +}) + it('restores selected providers in place while retaining newer tasks, plugins, settings and unrelated providers', async () => { const f = await fixture() const db = f.database() diff --git a/apps/buddy/service/src/storage/backupDatabase.ts b/apps/buddy/service/src/storage/backupDatabase.ts index ef5547d06..bf8da1af9 100644 --- a/apps/buddy/service/src/storage/backupDatabase.ts +++ b/apps/buddy/service/src/storage/backupDatabase.ts @@ -1,7 +1,7 @@ import type { DatabaseSync, SQLInputValue } from 'node:sqlite' import type { BackupScope } from '../../../shared/storage/backupScope' import { storageError } from '../../../platform/storage/storageFiles' -import { backupMergeColumns, backupTables, backupTableScopes, databaseRowScope } from './backupDatabasePolicy' +import { backupCapabilityOwners, backupMergeColumns, backupTables, backupTableScopes, databaseRowScope } from './backupDatabasePolicy' import { reconcileRestoredTaskState } from './restoreTaskState' const quote = (name: string) => `"${name.replaceAll('"', '""')}"` @@ -42,6 +42,9 @@ export function selectBackupDatabase(database: DatabaseSync, scopes: readonly Ba added += Number(database.prepare(`INSERT OR IGNORE INTO temp.keep_buddies SELECT parent.rowid FROM buddies parent JOIN ${quote(table!)} child ON child.${quote(field!)} = parent.id JOIN temp.${quote(`keep_${table}`)} kept ON kept.id = child.rowid`).run().changes) } added += Number(database.prepare(`INSERT OR IGNORE INTO temp.keep_buddies SELECT parent.rowid FROM buddies parent JOIN buddies child JOIN temp.keep_buddies kept ON kept.id = child.rowid JOIN json_each(child.settings_json, '$.npcIds') npc ON npc.value = parent.id`).run().changes) + for (const [kind, table] of backupCapabilityOwners) { + added += Number(database.prepare(`INSERT OR IGNORE INTO temp.keep_extension_capabilities SELECT source.rowid FROM extension_capabilities source JOIN ${quote(table)} owner ON owner.id = source.id JOIN temp.${quote(`keep_${table}`)} kept ON kept.id = owner.rowid WHERE source.kind = ?`).run(kind).changes) + } for (const table of tables) { const relations = database.prepare(`PRAGMA foreign_key_list(${quote(table)})`).all() as { id: number, seq: number, table: string, from: string, to: string | null }[] for (const id of new Set(relations.map(item => item.id))) { @@ -71,10 +74,39 @@ export function selectBackupDatabase(database: DatabaseSync, scopes: readonly Ba finally { database.exec('PRAGMA foreign_keys = ON') } } +function restoredCapabilityIds(target: DatabaseSync, source: DatabaseSync, scopes: readonly BackupScope[]): Set { + const restored = new Set() + const sourceIdentity = source.prepare('SELECT * FROM extension_capabilities WHERE id = ? AND kind = ?') + const targetIdentity = target.prepare('SELECT * FROM extension_capabilities WHERE id = ? AND kind = ?') + const targetContribution = target.prepare('SELECT id FROM extension_capabilities WHERE extension_id = ? AND kind = ? AND contribution_id = ?') + const targetKind = target.prepare('SELECT kind FROM extension_capabilities WHERE id = ?') + for (const [kind, table] of backupCapabilityOwners) { + const findOwner = target.prepare(`SELECT 1 FROM ${quote(table)} WHERE id = ?`) + for (const owner of source.prepare(`SELECT id FROM ${quote(table)}`).iterate()) { + const existing = findOwner.get(owner.id!) + if (existing && !scopes.includes(backupTableScopes.get(table)!)) + continue + const incoming = sourceIdentity.get(owner.id!, kind) + const current = targetIdentity.get(owner.id!, kind) + if ((existing && !!incoming !== !!current) || (current && (!incoming || current.extension_id !== incoming.extension_id || current.contribution_id !== incoming.contribution_id))) + throw storageError('SNAPSHOT_DEPENDENCY_CONFLICT') + if (!incoming) + continue + const contribution = targetContribution.get(incoming.extension_id!, kind, incoming.contribution_id!) + const identity = targetKind.get(owner.id!) + if ((contribution && contribution.id !== owner.id) || (identity && identity.kind !== kind)) + throw storageError('SNAPSHOT_DEPENDENCY_CONFLICT') + restored.add(owner.id!) + } + } + return restored +} + export function mergeBackupDatabase(target: DatabaseSync, source: DatabaseSync, scopes: readonly BackupScope[]): { added: number, updated: number, dependencies: number } { const counts = { added: 0, updated: 0, dependencies: 0 } target.exec('PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE') try { + const capabilities = restoredCapabilityIds(target, source, scopes) const triggers = target.prepare('SELECT name, sql FROM sqlite_master WHERE type = \'trigger\' AND sql LIKE \'%AFTER %\'').all() for (const trigger of triggers) target.exec(`DROP TRIGGER ${quote(String(trigger.name))}`) @@ -90,13 +122,15 @@ export function mergeBackupDatabase(target: DatabaseSync, source: DatabaseSync, const insert = target.prepare(`INSERT INTO ${quote(table)} (${names.map(quote).join(',')}) VALUES (${names.map(() => '?').join(',')})`) const update = target.prepare(`UPDATE ${quote(table)} SET ${names.map(name => `${quote(name)} = ?`).join(',')} WHERE ${where}`) for (const row of source.prepare(`SELECT * FROM ${quote(table)}${order.length ? ` ORDER BY ${order.map(quote).join(',')}` : ''}`).iterate()) { + if (table === 'extension_capabilities' && !capabilities.has(row.id!)) + continue const scope = databaseRowScope(table, row) if (!scope) continue const selected = scopes.includes(scope) const key = keys.map(name => row[name] as SQLInputValue) const existing = find.get(...key) - if (existing && !selected) + if (existing && !selected && table !== 'extension_capabilities') continue const values = names.map(name => row[name] as SQLInputValue) if (existing) { diff --git a/apps/buddy/service/src/storage/backupDatabasePolicy.ts b/apps/buddy/service/src/storage/backupDatabasePolicy.ts index 8e27beab7..bfc81eb3b 100644 --- a/apps/buddy/service/src/storage/backupDatabasePolicy.ts +++ b/apps/buddy/service/src/storage/backupDatabasePolicy.ts @@ -10,11 +10,14 @@ const domains: Partial> = { tasks: ['conversation_branches', 'messages', 'run_events', 'turn_requests', 'run_inputs', 'command_requests', 'approvals', 'attachments', 'space_events', 'automation_occurrences', 'automation_mutation_requests', 'run_change_sets', 'space_directory_bindings', 'spaces', 'conversations', 'runs', 'conversation_directory_grants', 'artifacts', 'run_file_change_captures', 'composer_resources', 'conversation_pi_trees', 'run_tree_sources', 'task_marks', 'task_attention', 'composer_drafts', 'extension_invocations', 'usage_records', 'conversation_members', 'execution_threads', 'execution_thread_bindings', 'execution_state', 'execution_work_attempts', 'execution_mailbox_sources', 'execution_mailbox_receipts', 'run_change_invocations', 'conversation_public_heads', 'conversation_publications', 'run_public_snapshots', 'run_session_reference_scopes', 'session_reference_exclusions', 'actor_public_cursors'], } export const backupTableScopes = new Map(Object.entries(domains).flatMap(([scope, tables]) => tables.map(table => [table, scope as BackupScope] as const))) -export const backupTables = [...backupTableScopes.keys(), 'workspace_settings'] +export const backupTables = [...backupTableScopes.keys(), 'workspace_settings', 'extension_capabilities'] +export const backupCapabilityOwners = [['subagent', 'buddies'], ['mcp', 'mcp_servers']] as const export function databaseRowScope(table: string, row: Record): BackupScope | null { if (table === 'workspace_settings') return row.key === 'buddy.web' ? 'connections' : row.key === 'buddy.chat.workspace.v2' ? 'settings' : null + if (table === 'extension_capabilities') + return row.kind === 'subagent' ? 'buddies' : row.kind === 'mcp' ? 'connections' : null return backupTableScopes.get(table) ?? null } diff --git a/apps/buddy/service/src/storage/buddyRepository.ts b/apps/buddy/service/src/storage/buddyRepository.ts index 505b35801..b83fb6f8d 100644 --- a/apps/buddy/service/src/storage/buddyRepository.ts +++ b/apps/buddy/service/src/storage/buddyRepository.ts @@ -1,19 +1,33 @@ 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 { isBuddyNpcBindable } from '../../../shared/buddies/buddyAvailability' import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings' +import { isManagedCapabilityEnabled } from '../../../shared/extensions/managedCapability' import { BuddyServiceError } from '../rpc/runtimeRequest' import { withTransaction } from './database' +import { createManagedCapabilityRepository, toManagedCapabilitySource } from './managedCapabilityRepository' export function createBuddyRepository(database: DatabaseSync) { + const managedCapabilities = createManagedCapabilityRepository(database) const select = 'SELECT id, kind, enabled, name, avatar, custom_avatars_json, description, settings_json, deleted_at AS deletedAt, revision, created_at AS createdAt, updated_at AS updatedAt FROM buddies' + function project(row: Record): LocalBuddy { + const buddy = toBuddy(row) + const source = managedCapabilities.get(buddy.id) + if (!source) + return buddy + const managed = toManagedCapabilitySource(source) + return { ...buddy, managed, enabled: source.kind === 'subagent' && isManagedCapabilityEnabled(managed) } + } + function get(id: string): LocalBuddy { const row = database.prepare(`${select} WHERE id = ?`).get(id) if (!row) throw new BuddyServiceError('BUDDY_NOT_FOUND') - return toBuddy(row) + return project(row) } function state() { @@ -21,7 +35,7 @@ export function createBuddyRepository(database: DatabaseSync) { } function list(): BuddyCatalog { - return { ...state(), buddies: database.prepare(`${select} WHERE deleted_at IS NULL ORDER BY created_at, id`).all().map(toBuddy) } + return { ...state(), buddies: database.prepare(`${select} WHERE deleted_at IS NULL ORDER BY created_at, id`).all().map(project) } } function active(id: string) { @@ -44,27 +58,35 @@ export function createBuddyRepository(database: DatabaseSync) { function updateProfile(input: BuddyUpdate): LocalBuddy { const current = writable(input) + const parsedSettings = buddySettingsSchema.parse(input.settings ?? current.settings) + if (current.managed && ((input.name !== undefined && input.name !== current.name) + || (input.description !== undefined && input.description !== current.description) + || JSON.stringify({ ...parsedSettings, model: null }) !== JSON.stringify({ ...current.settings, model: null }))) { + throw new BuddyServiceError('BUDDY_MANAGED_PROTECTED') + } const name = input.name ?? current.name const avatar = input.avatar === undefined ? current.avatar : input.avatar const customAvatars = avatarGallery(input.customAvatars ?? current.customAvatars, avatar) const description = input.description ?? current.description - const enabled = input.enabled ?? current.enabled + const previousEnabled = current.managed?.userEnabled ?? current.enabled + const enabled = input.enabled ?? previousEnabled if (!enabled && state().defaultBuddyId === current.id) throw new BuddyServiceError('BUDDY_DEFAULT_PROTECTED') - const parsedSettings = buddySettingsSchema.parse(input.settings ?? current.settings) const npcIds = [...new Set(parsedSettings.npcIds)] if (current.kind === 'npc' && npcIds.length) throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') - if (input.settings && parsedSettings.npcTeamEnabled) { - for (const id of npcIds) { + if (input.settings) { + for (const id of npcIds.filter(id => !current.settings.npcIds.includes(id))) { const npc = get(id) - if (id === current.id || npc.kind !== 'npc' || !npc.enabled || npc.deletedAt) + if (id === current.id || !isBuddyNpcBindable(npc)) throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') } } const settings = JSON.stringify({ ...parsedSettings, npcIds }) - if (enabled === current.enabled && name === current.name && avatar === current.avatar && customAvatars === JSON.stringify(current.customAvatars) && description === current.description && settings === JSON.stringify(current.settings)) + if (enabled === previousEnabled && name === current.name && avatar === current.avatar && customAvatars === JSON.stringify(current.customAvatars) && description === current.description && settings === JSON.stringify(current.settings)) return current + if (current.managed && enabled !== previousEnabled) + managedCapabilities.setUserEnabled(current.id, enabled) database.prepare('UPDATE buddies SET name = ?, avatar = ?, custom_avatars_json = ?, description = ?, settings_json = ?, enabled = ?, revision = revision + 1, updated_at = ? WHERE id = ?') .run(name, avatar, customAvatars, description, settings, Number(enabled), new Date().toISOString(), current.id) changed() @@ -88,6 +110,73 @@ export function createBuddyRepository(database: DatabaseSync) { }) }, update: (input: BuddyUpdate) => withTransaction(database, () => updateProfile(input)), + upsertManagedNpc(input: { id: string, name: string, description: string, settings: BuddySettings }): LocalBuddy { + return withTransaction(database, () => { + const source = managedCapabilities.get(input.id) + if (!source || source.kind !== 'subagent') + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + const incoming = buddySettingsSchema.parse(input.settings) + if (incoming.npcIds.length) + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + const now = new Date().toISOString() + const row = database.prepare(`${select} WHERE id = ?`).get(input.id) + const current = row ? toBuddy(row) : null + const settings = JSON.stringify(current + ? { ...current.settings, instructions: incoming.instructions, tools: incoming.tools, skills: incoming.skills, requiredSkills: incoming.requiredSkills, limits: incoming.limits } + : incoming) + if (!current) { + 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 (current.kind !== 'npc') { + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + } + else { + if (current.name === input.name && current.description === input.description && JSON.stringify(current.settings) === settings) + return get(input.id) + database.prepare('UPDATE buddies SET name = ?, description = ?, settings_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?') + .run(input.name, input.description, settings, now, input.id) + } + changed() + return get(input.id) + }) + }, + refreshManaged(id: string): LocalBuddy { + return withTransaction(database, () => { + const buddy = get(id) + if (!buddy.managed) + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + database.prepare('UPDATE buddies SET revision = revision + 1, updated_at = ? WHERE id = ?').run(new Date().toISOString(), id) + changed() + return get(id) + }) + }, + clearManagedConfiguration(id: string): LocalBuddy { + return withTransaction(database, () => { + const buddy = get(id) + if (!buddy.managed) + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + const settings = { ...buddy.settings, model: null, npcTeamEnabled: DEFAULT_BUDDY_SETTINGS.npcTeamEnabled, npcConcurrency: DEFAULT_BUDDY_SETTINGS.npcConcurrency } + managedCapabilities.setUserEnabled(id, false) + database.prepare('UPDATE buddies SET settings_json = ?, enabled = 0, avatar = NULL, custom_avatars_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?') + .run(JSON.stringify(settings), '[]', new Date().toISOString(), id) + changed() + return get(id) + }) + }, + duplicate(input: BuddyMutation): LocalBuddy { + return withTransaction(database, () => { + const current = writable(input) + if (current.kind !== 'npc') + throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING') + const id = randomUUID() + const now = new Date().toISOString() + database.prepare('INSERT INTO buddies (id, kind, name, avatar, custom_avatars_json, description, settings_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run(id, 'npc', current.name, current.avatar, JSON.stringify(current.customAvatars), current.description, JSON.stringify(current.settings), now, now) + changed() + return get(id) + }) + }, resetProfile(input: BuddyMutation): LocalBuddy { return withTransaction(database, () => { writable(input) @@ -99,6 +188,8 @@ export function createBuddyRepository(database: DatabaseSync) { delete(input: BuddyMutation): LocalBuddy { return withTransaction(database, () => { const current = writable(input) + if (current.managed) + throw new BuddyServiceError('BUDDY_MANAGED_PROTECTED') if (current.id === BUILTIN_BUDDY_ID) throw new BuddyServiceError('BUDDY_BUILTIN_PROTECTED') if (state().defaultBuddyId === current.id) diff --git a/apps/buddy/service/src/storage/connectorRepository.ts b/apps/buddy/service/src/storage/connectorRepository.ts index 2cb98bed3..df07fa059 100644 --- a/apps/buddy/service/src/storage/connectorRepository.ts +++ b/apps/buddy/service/src/storage/connectorRepository.ts @@ -1,11 +1,17 @@ import type { DatabaseSync } from 'node:sqlite' import type { McpToolExposure } from '../../../shared/connectors/mcpToolExposure' +import type { ManagedCapabilitySource } from '../../../shared/extensions/managedCapability' +import type { ManagedCapabilityRecord } from './managedCapabilityRepository' import { createHash } from 'node:crypto' +import { managedMcpCredentialTarget } from '../../../shared/connectors/managedMcp' import { mcpNamespaceBase } from '../../../shared/connectors/mcpToolExposure' +import { isManagedCapabilityEnabled } from '../../../shared/extensions/managedCapability' +import { createManagedCapabilityRepository, toManagedCapabilitySource } from './managedCapabilityRepository' export type McpTransport = 'stdio' | 'streamable-http' export interface McpServerRecord { + managed?: ManagedCapabilitySource id: string name: string toolNamespace: string @@ -23,10 +29,12 @@ export interface McpServerRecord { } export interface ConnectorRepository { + configureManaged: (record: McpServerWrite, configuration: Pick) => McpServerRecord readCatalog: (id: string) => { toolsJson: string, updatedAt: string } | null saveCatalog: (id: string, toolsJson: string, updatedAt: string) => void clearCatalog: (id: string) => void findById: (id: string) => McpServerRecord | null + findStoredById: (id: string) => McpServerRecord | null list: () => McpServerRecord[] remove: (id: string) => boolean upsert: (record: McpServerWrite) => McpServerRecord @@ -52,6 +60,19 @@ interface McpServerRow { } export function createConnectorRepository(database: DatabaseSync): ConnectorRepository { + const managed = createManagedCapabilityRepository(database) + function project(record: McpServerRecord): McpServerRecord { + const source = managed.get(record.id) + if (!source || source.kind !== 'mcp' || !('authentication' in source.definition)) + return record + const definition = source.definition + const metadata = toManagedCapabilitySource(source) + if (((source.credentialTarget !== null || record.credentialRef !== null) && source.credentialTarget !== managedMcpCredentialTarget(definition)) || record.url !== definition.url) + metadata.unavailableReason = 'target_changed' + else if (!metadata.unavailableReason && definition.authentication !== 'none' && !record.credentialRef) + metadata.unavailableReason = 'authentication_required' + return { ...record, name: definition.name, transport: definition.transport, url: definition.url, command: null, args: null, cwd: null, managed: metadata, enabled: isManagedCapabilityEnabled(metadata) } + } const namespaceOwner = database.prepare('SELECT id FROM mcp_servers WHERE tool_namespace = ?') function allocateNamespace(name: string, id: string): string { const base = mcpNamespaceBase(name) || 'server' @@ -95,7 +116,26 @@ export function createConnectorRepository(database: DatabaseSync): ConnectorRepo enabled = excluded.enabled, updated_at = excluded.updated_at `) - return { + const repository: ConnectorRepository = { + configureManaged(record, configuration) { + const source = managed.get(record.id) + if (!source || source.kind !== 'mcp') + throw new Error('MCP_MANAGED_SOURCE_REQUIRED') + database.exec('SAVEPOINT mcp_configuration') + try { + repository.upsert(record) + managed.setCredentialTarget(record.id, configuration.credentialTarget) + managed.setUnavailableReason(record.id, configuration.unavailableReason) + managed.setUserEnabled(record.id, configuration.userEnabled) + const saved = repository.findById(record.id)! + database.exec('RELEASE mcp_configuration') + return saved + } + catch (error) { + database.exec('ROLLBACK TO mcp_configuration; RELEASE mcp_configuration') + throw error + } + }, readCatalog(id) { const row = readCatalog.get(id) as { tools_json: string, updated_at: string } | undefined return row ? { toolsJson: row.tools_json, updatedAt: row.updated_at } : null @@ -106,12 +146,16 @@ export function createConnectorRepository(database: DatabaseSync): ConnectorRepo clearCatalog(id) { clearCatalog.run(id) }, - findById(id) { + findStoredById(id) { const row = find.get(id) as McpServerRow | undefined return row ? toMcpServer(row) : null }, + findById(id) { + const row = find.get(id) as McpServerRow | undefined + return row ? project(toMcpServer(row)) : null + }, list() { - return (list.all() as unknown as McpServerRow[]).map(toMcpServer) + return (list.all() as unknown as McpServerRow[]).map(row => project(toMcpServer(row))) }, remove(id) { return Number(remove.run(id).changes) === 1 @@ -138,9 +182,10 @@ export function createConnectorRepository(database: DatabaseSync): ConnectorRepo toolNamespace, record.toolExposure ?? existing?.tool_exposure ?? 'deferred', ) - return requireMcpServer(find.get(record.id), record.id) + return project(requireMcpServer(find.get(record.id), record.id)) }, } + return repository } function requireMcpServer(value: unknown, id: string): McpServerRecord { diff --git a/apps/buddy/service/src/storage/database.ts b/apps/buddy/service/src/storage/database.ts index 81ce8d5e9..5312c38c5 100644 --- a/apps/buddy/service/src/storage/database.ts +++ b/apps/buddy/service/src/storage/database.ts @@ -43,6 +43,7 @@ const BUDDY_CURRENT_SCHEMA_COLUMNS = { buddy_catalog: ['id', 'default_buddy_id', 'revision'], conversation_members: ['conversation_id', 'actor_id', 'model_selection_json', 'model_selection_source', 'joined_at', 'removed_at'], mcp_servers: ['tool_namespace', 'tool_exposure'], + extension_capabilities: ['id', 'extension_id', 'kind', 'contribution_id', 'revision', 'definition_json', 'state', 'user_enabled', 'credential_target'], run_event_checkpoints: ['run_id', 'last_sequence', 'projection_version', 'file_fingerprint'], extension_invocations: ['id', 'extension_id', 'action_id', 'conversation_id', 'trigger', 'status', 'branch_id', 'source_message_id', 'extension_name', 'action_title', 'result_message'], usage_records: ['run_id', 'invocation_id'], diff --git a/apps/buddy/service/src/storage/executionRepository.ts b/apps/buddy/service/src/storage/executionRepository.ts index 665ba37b7..da2952cae 100644 --- a/apps/buddy/service/src/storage/executionRepository.ts +++ b/apps/buddy/service/src/storage/executionRepository.ts @@ -11,6 +11,8 @@ import { executionMailboxMessageSchema } from '../../../shared/executions/execut import { executionReviewSchema, executionSubmissionSchema } from '../../../shared/executions/executionWork' import { isExecutionProfileWithin } from '../../../shared/permissions/executionProfile' import { buddyExecutionDefinitionSchema } from '../buddies/BuddyExecutionDefinition' +import { readAvailableBuddy, readAvailableNpcIds } from '../buddies/readAvailableBuddy' +import { createBuddyRepository } from './buddyRepository' import { freezeConversationMemberModel } from './conversationMemberRepository' import { createConversationPublicationRepository } from './conversationPublicationRepository' import { withTransaction } from './database' @@ -92,6 +94,7 @@ export function executionFence(record: ExecutionStateRecord, root: ExecutionStat } export function createExecutionRepository(database: DatabaseSync) { + const buddies = createBuddyRepository(database) const runs = createRunRepository(database) const threads = createThreadBindingRepository(database) const inputs = createRunInputRepository(database) @@ -345,9 +348,16 @@ export function createExecutionRepository(database: DatabaseSync) { if (listTree(root.run.id).length - 1 >= MAX_CHILD_ATTEMPTS_PER_ROOT) throw new ExecutionAdmissionError('budget_exceeded') const snapshot = buddyExecutionDefinitionSchema.parse(input.run.actorSnapshot) - const target = database.prepare('SELECT kind, revision, enabled, deleted_at, settings_json FROM buddies WHERE id = ?').get(input.run.buddyId) - if (!target || target.enabled !== 1 || target.deleted_at !== null || target.revision !== snapshot.revision - || target.kind !== snapshot.kind || JSON.stringify(buddySettingsSchema.parse(JSON.parse(target.settings_json as string))) !== JSON.stringify(snapshot.settings) || snapshot.id !== input.run.buddyId + const target = readAvailableBuddy(buddies.get, input.run.buddyId) + const targetSettings = target ? buddySettingsSchema.parse(target.settings) : null + if (targetSettings) { + if (snapshot.settings.npcIds.some(id => !targetSettings.npcIds.includes(id))) + throw new ExecutionAdmissionError('unauthorized') + targetSettings.npcIds = targetSettings.npcTeamEnabled ? readAvailableNpcIds(buddies.get, snapshot.settings.npcIds) : [] + snapshot.settings.npcIds = snapshot.settings.npcTeamEnabled ? readAvailableNpcIds(buddies.get, snapshot.settings.npcIds) : [] + } + if (!target || target.revision !== snapshot.revision + || target.kind !== snapshot.kind || JSON.stringify(targetSettings) !== JSON.stringify(snapshot.settings) || snapshot.id !== input.run.buddyId || input.run.provider !== snapshot.model.providerId || input.run.model !== snapshot.model.modelId || input.initialInput.reasoning !== snapshot.model.reasoning || input.initialInput.serviceTier !== snapshot.model.serviceTier || !caller.run.actorSnapshot || caller.run.actorSnapshot.kind !== 'actor') { diff --git a/apps/buddy/service/src/storage/managedCapabilityRepository.ts b/apps/buddy/service/src/storage/managedCapabilityRepository.ts new file mode 100644 index 000000000..97a909ad5 --- /dev/null +++ b/apps/buddy/service/src/storage/managedCapabilityRepository.ts @@ -0,0 +1,86 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ExtensionMcpConnectorContribution } from '../../../shared/extensions/extensionMcpConnectors' +import type { ExtensionSubagentContribution } from '../../../shared/extensions/extensionSubagents' +import type { ManagedCapabilitySource, ManagedCapabilityState, ManagedCapabilityUnavailableReason } from '../../../shared/extensions/managedCapability' +import { randomUUID } from 'node:crypto' +import { extensionMcpConnectorContributionSchema } from '../../../shared/extensions/extensionMcpConnectors' +import { extensionSubagentContributionSchema } from '../../../shared/extensions/extensionSubagents' +import { managedCapabilitySourceSchema } from '../../../shared/extensions/managedCapability' + +export type ManagedCapabilityRecord = ManagedCapabilitySource & { + id: string + credentialTarget: string | null +} & ({ kind: 'mcp', definition: ExtensionMcpConnectorContribution } | { kind: 'subagent', definition: ExtensionSubagentContribution }) +export type ManagedCapabilityInput = Pick + +export function toManagedCapabilitySource(record: ManagedCapabilityRecord): ManagedCapabilitySource { + return { extensionId: record.extensionId, extensionName: record.extensionName, contributionId: record.contributionId, revision: record.revision, state: record.state, userEnabled: record.userEnabled, unavailableReason: record.unavailableReason } +} + +export function createManagedCapabilityRepository(database: DatabaseSync) { + const get = (id: string): ManagedCapabilityRecord | null => parse(database.prepare('SELECT * FROM extension_capabilities WHERE id = ?').get(id)) + return { + get, + find(extensionId: string, kind: ManagedCapabilityRecord['kind'], contributionId: string): ManagedCapabilityRecord | null { + return parse(database.prepare('SELECT * FROM extension_capabilities WHERE extension_id = ? AND kind = ? AND contribution_id = ?').get(extensionId, kind, contributionId)) + }, + list(extensionId?: string): ManagedCapabilityRecord[] { + const rows = extensionId === undefined + ? database.prepare('SELECT * FROM extension_capabilities ORDER BY extension_id, kind, contribution_id').all() + : database.prepare('SELECT * FROM extension_capabilities WHERE extension_id = ? ORDER BY kind, contribution_id').all(extensionId) + return rows.map(row => parse(row)!) + }, + listKind(kind: ManagedCapabilityRecord['kind']): ManagedCapabilityRecord[] { + return database.prepare('SELECT * FROM extension_capabilities WHERE kind = ? ORDER BY extension_id, contribution_id').all(kind).map(row => parse(row)!) + }, + upsertSource(input: ManagedCapabilityInput): ManagedCapabilityRecord { + const existing = database.prepare('SELECT id FROM extension_capabilities WHERE extension_id = ? AND kind = ? AND contribution_id = ?').get(input.extensionId, input.kind, input.contributionId) as { id: string } | undefined + let id = existing?.id ?? randomUUID() + if (!existing) { + while (database.prepare('SELECT 1 FROM buddies WHERE id = ? UNION ALL SELECT 1 FROM mcp_servers WHERE id = ? UNION ALL SELECT 1 FROM extension_capabilities WHERE id = ? LIMIT 1').get(id, id, id)) + id = randomUUID() + } + const definition = input.kind === 'mcp' ? extensionMcpConnectorContributionSchema.parse(input.definition) : extensionSubagentContributionSchema.parse(input.definition) + database.prepare(`INSERT INTO extension_capabilities (id, extension_id, extension_name, kind, contribution_id, revision, definition_json, state, user_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET extension_name = excluded.extension_name, revision = excluded.revision, definition_json = excluded.definition_json, state = excluded.state`) + .run(id, input.extensionId, input.extensionName, input.kind, input.contributionId, input.revision, JSON.stringify(definition), input.state, Number(input.kind === 'subagent')) + return get(id)! + }, + setState(id: string, state: ManagedCapabilityState): void { + database.prepare('UPDATE extension_capabilities SET state = ? WHERE id = ?').run(state, id) + }, + setUserEnabled(id: string, enabled: boolean): void { + database.prepare('UPDATE extension_capabilities SET user_enabled = ? WHERE id = ?').run(Number(enabled), id) + }, + setUnavailableReason(id: string, reason: ManagedCapabilityUnavailableReason | null): void { + database.prepare('UPDATE extension_capabilities SET unavailable_reason = ? WHERE id = ?').run(reason, id) + }, + setCredentialTarget(id: string, target: string | null): void { + database.prepare('UPDATE extension_capabilities SET credential_target = ? WHERE id = ?').run(target, id) + }, + clearConfiguration(id: string): void { + database.prepare('UPDATE extension_capabilities SET user_enabled = 0, credential_target = NULL, unavailable_reason = NULL WHERE id = ?').run(id) + }, + suspendAll(): void { + database.prepare('UPDATE extension_capabilities SET state = \'unavailable\' WHERE state IN (\'enabled\', \'disabled\')').run() + }, + } +} + +export type ManagedCapabilityRepository = ReturnType + +function parse(value: unknown): ManagedCapabilityRecord | null { + if (!value) + return null + const row = value as { id: string, extension_id: string, extension_name: string, kind: ManagedCapabilityRecord['kind'], contribution_id: string, revision: string, definition_json: string, state: ManagedCapabilityState, user_enabled: number, credential_target: string | null, unavailable_reason: ManagedCapabilityUnavailableReason | null } + const definition: unknown = JSON.parse(row.definition_json) + return { + ...managedCapabilitySourceSchema.parse({ extensionId: row.extension_id, extensionName: row.extension_name, contributionId: row.contribution_id, revision: row.revision, state: row.state, userEnabled: row.user_enabled === 1, unavailableReason: row.unavailable_reason }), + id: row.id, + credentialTarget: row.credential_target, + ...(row.kind === 'mcp' + ? { kind: 'mcp', definition: extensionMcpConnectorContributionSchema.parse(definition) } + : { kind: 'subagent', definition: extensionSubagentContributionSchema.parse(definition) }), + } +} diff --git a/apps/buddy/service/src/storage/migrations/v36ExtensionCapabilities.ts b/apps/buddy/service/src/storage/migrations/v36ExtensionCapabilities.ts new file mode 100644 index 000000000..6c5d2a063 --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v36ExtensionCapabilities.ts @@ -0,0 +1,17 @@ +export const BUDDY_V36_EXTENSION_CAPABILITIES_SCHEMA_SQL = ` +CREATE TABLE extension_capabilities ( + id TEXT PRIMARY KEY, + extension_id TEXT NOT NULL, + extension_name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('mcp', 'subagent')), + contribution_id TEXT NOT NULL, + revision TEXT NOT NULL, + definition_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('enabled', 'disabled', 'removed', 'unavailable')), + user_enabled INTEGER NOT NULL CHECK (user_enabled IN (0, 1)), + unavailable_reason TEXT CHECK (unavailable_reason IN ('authentication_required', 'target_changed', 'dependency_unavailable')), + credential_target TEXT, + bindings_revision TEXT, + UNIQUE(extension_id, kind, contribution_id) +); +` diff --git a/apps/buddy/service/src/storage/restorePolicy.ts b/apps/buddy/service/src/storage/restorePolicy.ts index 355c18cce..73462ab69 100644 --- a/apps/buddy/service/src/storage/restorePolicy.ts +++ b/apps/buddy/service/src/storage/restorePolicy.ts @@ -13,6 +13,9 @@ export function sanitizeRestoredDatabase(database: DatabaseSync, preserveCredent UPDATE conversation_directory_grants SET revoked_at = datetime('now'); UPDATE space_directory_bindings SET revoked_at = datetime('now'), resources_trusted_at = NULL; UPDATE mcp_servers SET enabled = 0, trusted_at = NULL; + UPDATE extension_capabilities SET state = 'unavailable', + unavailable_reason = CASE WHEN kind = 'subagent' THEN 'dependency_unavailable' ELSE NULL END, + user_enabled = CASE WHEN kind = 'mcp' THEN 0 ELSE user_enabled END; ${preserveCredentials ? '' : 'UPDATE mcp_servers SET credential_ref = NULL; UPDATE provider_configs SET credential_ref = NULL;'} UPDATE provider_states SET request_headers_json = '[]'; UPDATE skill_installations SET enabled = 0; diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts index e20af55ce..6f4b3e31d 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -36,6 +36,7 @@ import { BUDDY_V32_CHANGE_CAPTURE_SCHEMA_SQL } from './migrations/v32ChangeCaptu import { BUDDY_V33_EXECUTION_WORK_SCHEMA_SQL } from './migrations/v33ExecutionWork' import { BUDDY_V34_ACTOR_IDENTITY_SCHEMA_SQL } from './migrations/v34ActorIdentity' import { BUDDY_V35_INPUT_DELIVERY_SCHEMA_SQL } from './migrations/v35InputDelivery' +import { BUDDY_V36_EXTENSION_CAPABILITIES_SCHEMA_SQL } from './migrations/v36ExtensionCapabilities' export interface BuddySchemaMigration { foreignKeys?: 'off' @@ -43,7 +44,7 @@ export interface BuddySchemaMigration { version: number } -export const BUDDY_SCHEMA_VERSION = 35 as const +export const BUDDY_SCHEMA_VERSION = 36 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -81,4 +82,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V33_EXECUTION_WORK_SCHEMA_SQL, version: 33 }, { sql: BUDDY_V34_ACTOR_IDENTITY_SCHEMA_SQL, version: 34 }, { sql: BUDDY_V35_INPUT_DELIVERY_SCHEMA_SQL, version: 35 }, + { sql: BUDDY_V36_EXTENSION_CAPABILITIES_SCHEMA_SQL, version: 36 }, ] diff --git a/apps/buddy/shared/buddies/buddyApi.ts b/apps/buddy/shared/buddies/buddyApi.ts index 8463b4215..105aa53e9 100644 --- a/apps/buddy/shared/buddies/buddyApi.ts +++ b/apps/buddy/shared/buddies/buddyApi.ts @@ -1,6 +1,7 @@ import type { RuntimeRequestContract } from '../runtime/apiContract' import type { DeepReadonly } from '../runtime/apiValidation' import { z } from 'zod' +import { managedCapabilitySourceSchema } from '../extensions/managedCapability' import { idSchema, timestampSchema, validationRequestSchemas } from '../runtime/apiValidation' import { buddySettingsSchema } from './buddySettings' @@ -14,6 +15,7 @@ export const buddySchema = z.object({ id: idSchema, kind: z.enum(['actor', 'npc']), enabled: z.boolean(), + managed: managedCapabilitySourceSchema.optional(), name: z.string().trim().min(1).max(80), avatar: buddyAvatarSchema, customAvatars: z.array(buddyAvatarImageSchema), @@ -69,6 +71,7 @@ export const buddiesRpc = { list: { method: 'buddies.list', input: validationRequestSchemas.empty, response: buddyCatalogSchema }, get: { method: 'buddies.get', input: buddyTargetSchema, response: buddySchema }, create: { method: 'buddies.create', input: buddyCreateSchema, response: buddySchema }, + duplicate: { method: 'buddies.duplicate', input: buddyMutationSchema, response: buddySchema }, delete: { method: 'buddies.delete', input: buddyMutationSchema, response: buddySchema }, resetProfile: { method: 'buddies.resetProfile', input: buddyMutationSchema, response: buddySchema }, setDefault: { method: 'buddies.setDefault', input: buddyDefaultUpdateSchema, response: buddyCatalogSchema }, diff --git a/apps/buddy/shared/buddies/buddyAvailability.ts b/apps/buddy/shared/buddies/buddyAvailability.ts new file mode 100644 index 000000000..c91af917b --- /dev/null +++ b/apps/buddy/shared/buddies/buddyAvailability.ts @@ -0,0 +1,6 @@ +import type { LocalBuddy } from './buddyApi' + +export function isBuddyNpcBindable(buddy: LocalBuddy): boolean { + return buddy.kind === 'npc' && !buddy.deletedAt + && (buddy.managed ? buddy.managed.state !== 'removed' : buddy.enabled) +} diff --git a/apps/buddy/shared/connectors/connectorApi.ts b/apps/buddy/shared/connectors/connectorApi.ts index 049ad9c64..77b600348 100644 --- a/apps/buddy/shared/connectors/connectorApi.ts +++ b/apps/buddy/shared/connectors/connectorApi.ts @@ -2,6 +2,7 @@ import type { RuntimeRequestContract } from '../runtime/apiContract' import type { DeepReadonly } from '../runtime/apiValidation' import { z } from 'zod' +import { managedCapabilitySourceSchema } from '../extensions/managedCapability' import { isHttpEndpointUrl } from '../network/networkSecurity' import { idSchema, isAbsolutePath, validationRequestSchemas, validationResponseSchemas } from '../runtime/apiValidation' import { editableConnectorCredentialSchema } from './connectorCredentials' @@ -12,6 +13,7 @@ const toolNamespaceSchema = z.string().regex(/^\w{1,32}$/) const toolExposureSchema = z.enum(MCP_TOOL_EXPOSURES) export const connectorBaseSchema = z.object({ + managed: managedCapabilitySourceSchema.optional(), credentialConfigured: z.boolean(), enabled: z.boolean(), executionConfirmed: z.boolean(), diff --git a/apps/buddy/shared/connectors/connectorState.ts b/apps/buddy/shared/connectors/connectorState.ts index 88d926076..ffe1fe32e 100644 --- a/apps/buddy/shared/connectors/connectorState.ts +++ b/apps/buddy/shared/connectors/connectorState.ts @@ -18,6 +18,7 @@ export const connectorErrorCodeSchema = z.enum([ 'MCP_TOOL_INVALID', 'MCP_NAMESPACE_CONFLICT', 'MCP_NAMESPACE_IMMUTABLE', + 'MCP_MANAGED_READ_ONLY', 'MCP_RESULT_TOO_LARGE', 'MCP_RESULT_STORAGE_DENIED', ]) diff --git a/apps/buddy/shared/connectors/managedMcp.ts b/apps/buddy/shared/connectors/managedMcp.ts new file mode 100644 index 000000000..bed30441a --- /dev/null +++ b/apps/buddy/shared/connectors/managedMcp.ts @@ -0,0 +1,5 @@ +import type { ExtensionMcpConnectorContribution } from '../extensions/extensionMcpConnectors' + +export function managedMcpCredentialTarget(definition: ExtensionMcpConnectorContribution): string { + return JSON.stringify([definition.transport, new URL(definition.url).href, definition.authentication]) +} 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..d31f1c02d --- /dev/null +++ b/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { extensionCapabilityDescriptorSchema } from '../extensionCapabilities' +import { extensionManifestSchema } from '../extensionManifest' +import { extensionMcpConnectorContributionSchema } from '../extensionMcpConnectors' +import { BUILTIN_SUBAGENT_IDS, extensionSubagentContributionSchema, subagentConnectorReferences } from '../extensionSubagents' + +const id = 'tests.legal' +const connector = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', authentication: 'bearer' } +const skill = { id: `${id}.research`, path: 'skills/research/SKILL.md' } +const tool = { source: 'mcp', connector: connector.id, name: 'search_statutes' } +const subagent = { + id: 'law-finder', + name: 'Law finder', + instructions: 'Search statutes first.', + skills: [skill.id], + requiredSkills: [skill.id], + tools: [tool], + fileAccess: 'inherit', +} + +function manifest(contributes: Record, patch: Record = {}) { + return { schemaVersion: 1, id, name: 'Legal', version: '1.0.0', apiVersion: 4, engines: { lexora: '*' }, contributes, ...patch } +} + +function capabilities(contributes: Record = {}) { + return manifest({ mcpConnectors: [connector], skills: [skill], subagents: [subagent], ...contributes }) +} + +describe('manifest capabilities', () => { + it('parses a declarative package with raw MCP tool names and package-owned skills', () => { + const parsed = extensionManifestSchema.parse(capabilities()) + expect(parsed.contributes.mcpConnectors).toEqual([connector]) + expect(parsed.contributes.subagents).toEqual([{ ...subagent, description: '' }]) + expect(parsed.entry).toBeUndefined() + expect(parsed.permissions.agent).toBe(false) + expect(parsed.permissions.network).toEqual([]) + }) + + it('accepts an independent MCP package without NPCs, Skills or permissions', () => { + const parsed = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector] })) + expect(parsed.contributes.mcpConnectors).toEqual([extensionMcpConnectorContributionSchema.parse(connector)]) + expect(parsed.contributes.subagents).toEqual([]) + expect(parsed.contributes.skills).toEqual([]) + expect(parsed.entry).toBeUndefined() + expect(parsed.permissions.agent).toBe(false) + expect(parsed.permissions.network).toEqual([]) + }) + + it('accepts an independent NPC package with only explicit builtin tools', () => { + const declaration = { id: 'reader', name: 'Reader', instructions: 'Read the supplied files.', tools: [{ source: 'builtin', name: 'read' }], fileAccess: 'read_only' } + const parsed = extensionManifestSchema.parse(manifest({ subagents: [declaration] })) + expect(parsed.contributes.subagents).toEqual([extensionSubagentContributionSchema.parse(declaration)]) + expect(parsed.contributes.mcpConnectors).toEqual([]) + expect(parsed.contributes.skills).toEqual([]) + expect(parsed.entry).toBeUndefined() + expect(parsed.permissions.agent).toBe(false) + expect(parsed.permissions.models).toBe(false) + }) + + it('scopes local IDs by contribution kind without inferring dependencies', () => { + const declaration = { id: connector.id, name: 'Reader', instructions: 'Inspect the task.', fileAccess: 'read_only' } + const parsed = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector], subagents: [declaration] })) + expect(parsed.contributes.mcpConnectors[0]?.id).toBe(parsed.contributes.subagents[0]?.id) + expect(parsed.contributes.subagents[0]?.tools).toEqual([]) + expect(parsed.contributes.subagents[0]?.skills).toEqual([]) + }) + + it.each(['9_reader', 'a'.repeat(64)])('accepts the local ID %s independently of plugin-prefixed IDs', (localId) => { + const parsed = extensionManifestSchema.parse(manifest({ + mcpConnectors: [{ ...connector, id: localId }], + subagents: [{ id: localId, name: 'Reader', instructions: 'Inspect the task.', fileAccess: 'read_only' }], + })) + expect(parsed.contributes.mcpConnectors[0]?.id).toBe(localId) + expect(parsed.contributes.subagents[0]?.id).toBe(localId) + }) + + it.each([`${id}.reader`, 'Reader', '_reader', 'a'.repeat(65)])('rejects the non-local contribution ID %s', (localId) => { + expect(extensionMcpConnectorContributionSchema.safeParse({ ...connector, id: localId }).success).toBe(false) + expect(extensionSubagentContributionSchema.safeParse({ ...subagent, id: localId }).success).toBe(false) + }) + + it.each(BUILTIN_SUBAGENT_IDS)('reserves the builtin NPC identity %s in the contribution schema', (builtinId) => { + expect(extensionSubagentContributionSchema.safeParse({ ...subagent, id: builtinId }).success).toBe(false) + }) + + it('defaults optional sub-agent fields without granting tools or skills', () => { + const declaration = { id: 'reader', name: 'Reader', instructions: 'Inspect the task.', fileAccess: 'read_only' } + const parsed = extensionManifestSchema.parse(manifest({ subagents: [declaration] })) + expect(parsed.contributes.subagents).toEqual([{ ...declaration, description: '', skills: [], requiredSkills: [], tools: [] }]) + expect(extensionManifestSchema.parse(manifest({})).contributes).toMatchObject({ mcpConnectors: [], subagents: [] }) + }) + + it.each(['none', 'bearer', 'oauth'])('accepts the %s authentication requirement without credentials', (authentication) => { + expect(extensionManifestSchema.parse(manifest({ mcpConnectors: [{ ...connector, authentication }] })).contributes.mcpConnectors[0]?.authentication).toBe(authentication) + }) + + it.each(['inherit', 'read_only'])('accepts the explicit %s file policy with the stable read-only builtin tools', (fileAccess) => { + const tools = ['read', 'find', 'grep', 'ls'].map(name => ({ source: 'builtin', name })) + const parsed = extensionManifestSchema.parse(capabilities({ subagents: [{ ...subagent, tools, fileAccess }] })) + expect(parsed.contributes.subagents[0]?.tools).toEqual(tools) + }) + + it.each([ + { mcpConnectors: [connector] }, + { subagents: [{ ...subagent, skills: [], requiredSkills: [], tools: [] }] }, + ])('requires API 4 for each contribution kind', (contributes) => { + expect(extensionManifestSchema.safeParse(manifest(contributes, { apiVersion: 3 })).success).toBe(false) + }) + + it.each([ + ['an undeclared connector', { mcpConnectors: [] }], + ['a user connector', { subagents: [{ ...subagent, tools: [{ ...tool, connector: 'user-connection' }] }] }], + ['a foreign package connector', { subagents: [{ ...subagent, tools: [{ ...tool, connector: 'other.plugin.search' }] }] }], + ['an undeclared skill', { skills: [] }], + ['a foreign package skill', { subagents: [{ ...subagent, skills: ['other.plugin.research'], requiredSkills: [] }] }], + ['a required skill outside selected skills', { subagents: [{ ...subagent, skills: [] }] }], + ['a duplicate connector id', { mcpConnectors: [connector, connector] }], + ['a duplicate sub-agent id', { subagents: [subagent, subagent] }], + ['a duplicate skill reference', { subagents: [{ ...subagent, skills: [skill.id, skill.id] }] }], + ['a duplicate required skill', { subagents: [{ ...subagent, requiredSkills: [skill.id, skill.id] }] }], + ['a duplicate MCP tool', { subagents: [{ ...subagent, tools: [tool, { name: tool.name, connector: tool.connector, source: tool.source }] }] }], + ['a duplicate builtin tool', { subagents: [{ ...subagent, tools: [{ source: 'builtin', name: 'read' }, { source: 'builtin', name: 'read' }] }] }], + ['a built-in sub-agent id', { subagents: [{ ...subagent, id: 'npc-explorer' }] }], + ['empty instructions', { subagents: [{ ...subagent, instructions: '' }] }], + ['an omitted file policy', { subagents: [{ ...subagent, fileAccess: undefined }] }], + ['MCP tools under a read-only policy', { subagents: [{ ...subagent, fileAccess: 'read_only' }] }], + ['an omitted authentication requirement', { mcpConnectors: [{ ...connector, authentication: undefined }] }], + ['a local command transport', { mcpConnectors: [{ ...connector, transport: 'stdio', command: 'npx', args: [] }] }], + ['an embedded token', { mcpConnectors: [{ ...connector, token: 'example-token' }] }], + ['embedded headers', { mcpConnectors: [{ ...connector, headers: { Authorization: 'example-token' } }] }], + ['a manifest-selected model', { subagents: [{ ...subagent, model: { providerId: 'provider', modelId: 'model' } }] }], + ['a manifest-enabled NPC', { subagents: [{ ...subagent, enabled: true }] }], + ['a manifest-enabled connector', { mcpConnectors: [{ ...connector, enabled: true }] }], + ['an actor kind', { subagents: [{ ...subagent, kind: 'actor' }] }], + ['the old NPC kind', { subagents: [{ ...subagent, kind: 'npc' }] }], + ['the old deferred field', { mcpConnectors: [{ ...connector, deferred: true }] }], + ['the old credential field', { mcpConnectors: [{ ...connector, credential: 'bearer' }] }], + ['the old all-tools selection', { subagents: [{ ...subagent, tools: { mode: 'all' } }] }], + ['the old no-tools selection', { subagents: [{ ...subagent, tools: { mode: 'none' } }] }], + ['the old encoded selection', { subagents: [{ ...subagent, tools: { mode: 'selected', ids: [JSON.stringify(['mcp', connector.id, tool.name])] } }] }], + ['an encoded tool string', { subagents: [{ ...subagent, tools: [JSON.stringify(['mcp', connector.id, tool.name])] }] }], + ['an unsupported tool source', { subagents: [{ ...subagent, tools: [{ source: 'plugin', name: 'execute' }] }] }], + ['a builtin reference with a group', { subagents: [{ ...subagent, tools: [{ source: 'builtin', name: 'read', group: 'core' }] }] }], + ['an unnamed remote tool', { subagents: [{ ...subagent, tools: [{ ...tool, name: '' }] }] }], + ])('rejects %s', (_name, contributes) => { + expect(extensionManifestSchema.safeParse(capabilities(contributes)).success).toBe(false) + }) + + it.each(['bash', 'write', 'edit', 'lexora_agents', 'lexora_authorize_directory', 'all', '*'])('rejects the non-allowlisted builtin tool %s', (name) => { + const input = capabilities({ subagents: [{ ...subagent, tools: [{ source: 'builtin', name }] }] }) + expect(extensionManifestSchema.safeParse(input).success).toBe(false) + }) + + it.each([ + 'http://legal.example.com/mcp', + 'file:///tmp/mcp', + 'https://user:secret@legal.example.com/mcp', + 'https://user@legal.example.com/mcp', + 'https://:secret@legal.example.com/mcp', + 'https://user%40example.com@legal.example.com/mcp', + 'not a URL', + 'https://', + ])('rejects an invalid connector address: %s', (url) => { + expect(extensionManifestSchema.safeParse(capabilities({ mcpConnectors: [{ ...connector, url }] })).success).toBe(false) + }) + + it('permits distinct raw tools with the same name on separate declared connectors', () => { + const second = { ...connector, id: 'other-search' } + const tools = [tool, { ...tool, connector: second.id }, { source: 'builtin', name: 'read' }] + const parsed = extensionManifestSchema.parse(capabilities({ mcpConnectors: [connector, second], subagents: [{ ...subagent, tools }] })) + expect(parsed.contributes.subagents[0]?.tools).toEqual(tools) + }) + + it('reports invalid cross-references on the affected sub-agent field', () => { + const parsed = extensionManifestSchema.safeParse(capabilities({ skills: [], mcpConnectors: [] })) + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues.map(issue => issue.path)).toEqual(expect.arrayContaining([ + ['contributes', 'subagents', 0, 'tools'], + ['contributes', 'subagents', 0, 'skills'], + ['contributes', 'subagents', 0, 'requiredSkills'], + ])) + } + }) + + it('extracts distinct local connector references from structured tools', () => { + const base = extensionManifestSchema.parse(capabilities({ + subagents: [{ ...subagent, tools: [tool, { ...tool, name: 'get_statute' }, { source: 'builtin', name: 'read' }] }], + })).contributes.subagents[0]! + expect(subagentConnectorReferences(base)).toEqual([connector.id]) + expect(subagentConnectorReferences({ tools: [] })).toEqual([]) + expect(subagentConnectorReferences({ tools: [{ source: 'builtin', name: 'read' }] })).toEqual([]) + }) + + it.each([ + { mcpConnectors: [connector], subagents: [] }, + { mcpConnectors: [], subagents: [{ id: 'reader', name: 'Reader', instructions: 'Inspect the task.', fileAccess: 'read_only' }] }, + ])('exchanges independent contributions through strict lifecycle descriptors', (contributions) => { + const descriptor = { extensionId: id, extensionName: 'Legal', revision: 'a', enabled: true, ...contributions } + const parsed = extensionCapabilityDescriptorSchema.parse(descriptor) + expect(parsed).toEqual({ + ...descriptor, + mcpConnectors: contributions.mcpConnectors.map(value => extensionMcpConnectorContributionSchema.parse(value)), + subagents: contributions.subagents.map(value => extensionSubagentContributionSchema.parse(value)), + skills: [], + }) + expect(extensionCapabilityDescriptorSchema.parse({ ...descriptor, skills: [skill.id] }).skills).toEqual([skill.id]) + expect(extensionCapabilityDescriptorSchema.safeParse({ ...descriptor, extra: true }).success).toBe(false) + expect(extensionCapabilityDescriptorSchema.safeParse({ ...descriptor, bindings: [{ subagentId: 'reader', buddyId: 'buddy-main' }] }).success).toBe(false) + }) +}) diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 66fceb1a5..6224a55d8 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' @@ -172,6 +173,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(), @@ -213,6 +215,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..f323340ce --- /dev/null +++ b/apps/buddy/shared/extensions/extensionCapabilities.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { extensionMcpConnectorContributionSchema } from './extensionMcpConnectors.ts' +import { extensionSubagentContributionSchema } from './extensionSubagents.ts' + +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), + skills: z.array(z.string().min(1)).default([]), +}).strict() + +export type ExtensionCapabilityDescriptor = z.infer + +export const extensionCapabilityImpactSchema = z.object({ + retained: z.array(z.object({ kind: z.enum(['mcp', 'subagent']), name: z.string() }).strict()), + dependents: z.array(z.object({ id: z.string(), 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 extensionCapabilityApplyRpc = { + method: 'extensions.capabilities.apply', + input: z.object({ + descriptors: z.array(extensionCapabilityDescriptorSchema).max(512), + clearExtensionId: z.string().min(1).max(120).optional(), + }).strict(), + response: z.null(), +} as const + +export const extensionCapabilitiesRpc = { + apply: extensionCapabilityApplyRpc.method, + list: 'extensions.capabilities.list', + changed: 'extensions.capabilities.changed', + impact: extensionCapabilityImpactRpc.method, +} as const diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 1697a16e2..633625b9a 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -9,10 +9,12 @@ import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workb import { extensionAgentSchema } from './extensionAgent.ts' import { extensionConditionDefinitionSchema } from './extensionConditions.ts' import { extensionAuthorSchema } from './extensionIdentity.ts' +import { extensionMcpConnectorContributionSchema } from './extensionMcpConnectors.ts' import { extensionPathSchema } from './extensionPath.ts' import { extensionSettingsGroups, extensionSettingsModules, extensionSettingsSchema, validateExtensionSetting } from './extensionSettings.ts' import { extensionShellContributionSchema } from './extensionShell.ts' import { extensionSkillContributionSchema, extensionSkillRoots } from './extensionSkills.ts' +import { extensionSubagentContributionSchema, subagentConnectorReferences } from './extensionSubagents.ts' export const EXTENSION_API_VERSION = 4 export const EXTENSION_PROTOCOL = 'lexora-extension' @@ -131,6 +133,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([]), @@ -168,6 +172,23 @@ 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, skills } = manifest.contributes + for (const [field, contributions] of [['mcpConnectors', mcpConnectors], ['subagents', subagents]] as const) { + if (contributions.length && manifest.apiVersion < 4) + context.addIssue({ code: 'custom', path: ['contributes', field], message: `${field} contributions require API 4` }) + if (new Set(contributions.map(contribution => contribution.id)).size !== contributions.length) + context.addIssue({ code: 'custom', path: ['contributes', field], message: `${field} IDs must be unique within their contribution kind` }) + } + const connectorIds = new Set(mcpConnectors.map(connector => connector.id)) + const skillIds = new Set(skills.map(skill => skill.id)) + for (const [index, subagent] of subagents.entries()) { + if (subagentConnectorReferences(subagent).some(id => !connectorIds.has(id))) + context.addIssue({ code: 'custom', path: ['contributes', 'subagents', index, 'tools'], message: 'NPC tools may only reference MCP connectors declared by the same extension' }) + for (const field of ['skills', 'requiredSkills'] as const) { + if (subagent[field].some(id => !skillIds.has(id))) + context.addIssue({ code: 'custom', path: ['contributes', 'subagents', index, field], message: 'NPC skills must be 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/shared/extensions/extensionMcpConnectors.ts b/apps/buddy/shared/extensions/extensionMcpConnectors.ts new file mode 100644 index 000000000..a5de62eba --- /dev/null +++ b/apps/buddy/shared/extensions/extensionMcpConnectors.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const extensionMcpConnectorContributionSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/), + name: z.string().trim().min(1).max(128), + transport: z.literal('streamable-http'), + url: z.url().max(2048).refine((value) => { + if (!URL.canParse(value)) + return false + const url = new URL(value) + return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password + }, 'Expected an HTTPS address without credentials'), + authentication: z.enum(['none', 'bearer', 'oauth']), +}).strict() + +export type ExtensionMcpConnectorContribution = z.infer diff --git a/apps/buddy/shared/extensions/extensionSubagents.ts b/apps/buddy/shared/extensions/extensionSubagents.ts new file mode 100644 index 000000000..d2ba95ace --- /dev/null +++ b/apps/buddy/shared/extensions/extensionSubagents.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +const localId = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/) +const skillReferences = z.array(z.string().min(1).max(180)).max(32).refine(ids => new Set(ids).size === ids.length, 'Skill references must be unique') + +export const BUILTIN_SUBAGENT_IDS = ['npc-explorer', 'npc-researcher', 'npc-executor', 'npc-reviewer'] as const +export const EXTENSION_SUBAGENT_BUILTIN_TOOLS = ['read', 'find', 'grep', 'ls'] as const + +export const extensionSubagentToolReferenceSchema = z.discriminatedUnion('source', [ + z.object({ + source: z.literal('mcp'), + connector: localId, + name: z.string().min(1).max(256), + }).strict(), + z.object({ + source: z.literal('builtin'), + name: z.enum(EXTENSION_SUBAGENT_BUILTIN_TOOLS), + }).strict(), +]) +export type ExtensionSubagentToolReference = z.infer + +export const extensionSubagentContributionSchema = z.object({ + id: localId.refine(id => !(BUILTIN_SUBAGENT_IDS as readonly string[]).includes(id), 'NPC IDs must not reuse built-in ones'), + name: z.string().trim().min(1).max(80), + description: z.string().trim().max(500).default(''), + instructions: z.string().min(1).max(16000), + skills: skillReferences.default([]), + requiredSkills: skillReferences.default([]), + tools: z.array(extensionSubagentToolReferenceSchema).max(256).refine(tools => new Set(tools.map(tool => JSON.stringify(tool.source === 'mcp' + ? [tool.source, tool.connector, tool.name] + : [tool.source, tool.name]))).size === tools.length, 'Tool references must be unique').default([]), + fileAccess: z.enum(['inherit', 'read_only']), +}).strict().superRefine((subagent, context) => { + if (subagent.requiredSkills.some(id => !subagent.skills.includes(id))) + context.addIssue({ code: 'custom', path: ['requiredSkills'], message: 'Required skills must be a subset of skills' }) + if (subagent.fileAccess === 'read_only' && subagent.tools.some(tool => tool.source === 'mcp')) + context.addIssue({ code: 'custom', path: ['tools'], message: 'Read-only NPCs cannot use MCP tools' }) +}) + +export type ExtensionSubagentContribution = z.infer + +export function subagentConnectorReferences(subagent: Pick): string[] { + return [...new Set(subagent.tools.flatMap(tool => tool.source === 'mcp' ? [tool.connector] : []))] +} diff --git a/apps/buddy/shared/extensions/managedCapability.ts b/apps/buddy/shared/extensions/managedCapability.ts new file mode 100644 index 000000000..0f95f682d --- /dev/null +++ b/apps/buddy/shared/extensions/managedCapability.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const managedCapabilityStateSchema = z.enum(['enabled', 'disabled', 'removed', 'unavailable']) +export const managedCapabilityUnavailableReasonSchema = z.enum(['authentication_required', 'target_changed', 'dependency_unavailable']) +export const managedCapabilitySourceSchema = z.object({ + extensionId: z.string().min(1), + extensionName: z.string().min(1), + contributionId: z.string().min(1), + revision: z.string().min(1), + state: managedCapabilityStateSchema, + userEnabled: z.boolean(), + unavailableReason: managedCapabilityUnavailableReasonSchema.nullable(), +}).strict() + +export type ManagedCapabilitySource = z.infer +export type ManagedCapabilityState = z.infer +export type ManagedCapabilityUnavailableReason = z.infer + +export function isManagedCapabilityEnabled(source: ManagedCapabilitySource): boolean { + return source.state === 'enabled' && source.userEnabled && source.unavailableReason === null +} diff --git a/apps/buddy/shared/runtime/localChatError.ts b/apps/buddy/shared/runtime/localChatError.ts index fe471a191..c5be525b7 100644 --- a/apps/buddy/shared/runtime/localChatError.ts +++ b/apps/buddy/shared/runtime/localChatError.ts @@ -24,6 +24,10 @@ export type LocalChatErrorCode | 'BUDDY_LEADER_PROTECTED' | 'BUDDY_INVALID_NPC_BINDING' | 'BUDDY_CAPABILITY_UNAVAILABLE' + | 'MCP_MANAGED_READ_ONLY' + | 'MCP_AUTHENTICATION_REQUIRED' + | 'MCP_CONNECTOR_DISABLED' + | 'BUDDY_MANAGED_PROTECTED' | 'BUDDY_BUILTIN_PROTECTED' | 'BUDDY_DEFAULT_PROTECTED' | 'BUDDY_RESET_UNAVAILABLE' @@ -103,6 +107,10 @@ const LOCAL_CHAT_ERROR_CODES = new Set([ 'BUDDY_LEADER_PROTECTED', 'BUDDY_INVALID_NPC_BINDING', 'BUDDY_CAPABILITY_UNAVAILABLE', + 'MCP_MANAGED_READ_ONLY', + 'MCP_AUTHENTICATION_REQUIRED', + 'MCP_CONNECTOR_DISABLED', + 'BUDDY_MANAGED_PROTECTED', 'BUDDY_BUILTIN_PROTECTED', 'BUDDY_DEFAULT_PROTECTED', 'BUDDY_RESET_UNAVAILABLE', diff --git a/apps/buddy/src/i18n/locales/en-US/app.ts b/apps/buddy/src/i18n/locales/en-US/app.ts index 6a001c5b3..ebc720cc3 100644 --- a/apps/buddy/src/i18n/locales/en-US/app.ts +++ b/apps/buddy/src/i18n/locales/en-US/app.ts @@ -8,6 +8,8 @@ export default { 'desktop.error.buddyInvalidNpcBinding': 'A bound NPC is unavailable. Check the actor configuration.', 'desktop.error.buddyCapabilityUnavailable': 'A selected capability is unavailable. Check the tool configuration.', 'desktop.error.buddyDeleted': 'This companion was removed and can no longer be selected or edited.', + 'desktop.error.mcpManagedReadOnly': 'This connection is managed by a plugin. Its source definition cannot be edited or deleted directly.', + 'desktop.error.buddyManagedProtected': 'This companion is managed by a plugin. Disable it, manage its plugin, or create a custom copy instead of editing or deleting its definition.', 'desktop.error.buddyBuiltinProtected': 'The built-in companion cannot be deleted.', 'desktop.error.buddyDefaultProtected': 'Choose another default companion before deleting this one.', 'desktop.error.buddyResetUnavailable': 'Only the built-in companion supports restoring its original profile.', diff --git a/apps/buddy/src/i18n/locales/en-US/mcp.ts b/apps/buddy/src/i18n/locales/en-US/mcp.ts index 9f5c8e7be..fa7e243b5 100644 --- a/apps/buddy/src/i18n/locales/en-US/mcp.ts +++ b/apps/buddy/src/i18n/locales/en-US/mcp.ts @@ -1,4 +1,18 @@ export default { + 'desktop.mcp.managed.source': 'From plugin: {name}', + 'desktop.mcp.managed.state.enabled': 'Plugin enabled', + 'desktop.mcp.managed.state.disabled': 'Plugin disabled', + 'desktop.mcp.managed.state.removed': 'Source removed', + 'desktop.mcp.managed.state.unavailable': 'Plugin unavailable', + 'desktop.mcp.managed.reason.authentication_required': 'Configure credentials or sign in before enabling.', + 'desktop.mcp.managed.reason.target_changed': 'The plugin changed the endpoint or authentication. Previous credentials are isolated. Replace or clear them, or sign in again.', + 'desktop.mcp.managed.reason.dependency_unavailable': 'A required dependency is unavailable.', + 'desktop.mcp.managed.readOnly': 'The plugin manages the name, transport and address. You control credentials, enabling and tool availability.', + 'desktop.mcp.managed.acceptTarget': 'Confirm new target', + 'desktop.mcp.managed.acceptTargetConfirm': 'Use the new target and clear previous credentials? Configure authentication again if required.', + 'desktop.mcp.managed.clear': 'Clear configuration', + 'desktop.mcp.managed.clearConfirm': 'Clear credentials and tool settings and disable this connection? The source identity will be retained.', + 'desktop.mcp.error.MCP_MANAGED_READ_ONLY': 'This connection is managed by a plugin. Its source definition cannot be edited or directly deleted.', 'desktop.mcp.namespace': 'Tool namespace', 'desktop.mcp.namespaceHint': 'Use letters, digits or underscores for the tool prefix. Leave blank to generate it. Fixed after saving; renaming the connection keeps tool calls stable.', 'desktop.mcp.exposure': 'Tool availability', diff --git a/apps/buddy/src/i18n/locales/zh-CN/app.ts b/apps/buddy/src/i18n/locales/zh-CN/app.ts index aee01c4c2..23e15c66f 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/app.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/app.ts @@ -6,6 +6,8 @@ export default { 'desktop.error.buddyInvalidNpcBinding': '绑定的 NPC 不可用,请检查角色配置', 'desktop.error.buddyCapabilityUnavailable': '角色选择的能力当前不可用,请检查工具配置', 'desktop.error.buddyDeleted': '伙伴已删除,无法再选择或修改', + 'desktop.error.mcpManagedReadOnly': '此连接由插件管理,不能修改来源定义或直接删除。', + 'desktop.error.buddyManagedProtected': '此伙伴由插件管理,定义不可编辑或删除。可停用、前往插件管理,或创建自定义副本。', 'desktop.error.buddyBuiltinProtected': '内置伙伴不可删除', 'desktop.error.buddyDefaultProtected': '请先将其他伙伴设为默认,再删除这个伙伴', 'desktop.error.buddyResetUnavailable': '只有内置伙伴支持恢复默认资料', diff --git a/apps/buddy/src/i18n/locales/zh-CN/mcp.ts b/apps/buddy/src/i18n/locales/zh-CN/mcp.ts index d5d397084..40ebe32b9 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/mcp.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/mcp.ts @@ -1,4 +1,18 @@ export default { + 'desktop.mcp.managed.source': '来自插件:{name}', + 'desktop.mcp.managed.state.enabled': '插件已启用', + 'desktop.mcp.managed.state.disabled': '插件已停用', + 'desktop.mcp.managed.state.removed': '来源已移除', + 'desktop.mcp.managed.state.unavailable': '插件暂不可用', + 'desktop.mcp.managed.reason.authentication_required': '需要配置凭据或完成登录后才能启用。', + 'desktop.mcp.managed.reason.target_changed': '插件更改了连接目标或认证方式。旧凭据已隔离,请重新配置、清除凭据或重新登录。', + 'desktop.mcp.managed.reason.dependency_unavailable': '所需依赖暂不可用。', + 'desktop.mcp.managed.readOnly': '名称、连接方式与地址由插件管理。你可以配置凭据、启停和工具使用方式。', + 'desktop.mcp.managed.acceptTarget': '确认新目标', + 'desktop.mcp.managed.acceptTargetConfirm': '使用新的连接目标,并清除旧凭据?需要认证时请重新配置。', + 'desktop.mcp.managed.clear': '清理配置', + 'desktop.mcp.managed.clearConfirm': '清除凭据和工具设置并停用此连接?将保留来源身份。', + 'desktop.mcp.error.MCP_MANAGED_READ_ONLY': '此连接由插件管理,不能修改来源定义或直接删除。', 'desktop.mcp.namespace': '工具命名空间', 'desktop.mcp.namespaceHint': '使用英文、数字或下划线作为工具前缀。留空自动生成;保存后固定,修改显示名称不会影响工具调用。', 'desktop.mcp.exposure': '工具使用方式', diff --git a/apps/buddy/src/modules/buddies/buddyLabels.ts b/apps/buddy/src/modules/buddies/buddyLabels.ts index 953e5c723..341fd9799 100644 --- a/apps/buddy/src/modules/buddies/buddyLabels.ts +++ b/apps/buddy/src/modules/buddies/buddyLabels.ts @@ -9,6 +9,14 @@ export function buddyLabels(language: string) { kind: 'Kind', actorHint: 'Participates in conversations and can delegate to bound NPCs.', npcHint: 'Reusable specialist called through delegation.', + managedSource: (name: string) => `Managed by ${name}`, + managedDefinitionHint: 'The plugin controls this definition. You can change the model and enabled preference. Disable this NPC or manage its plugin to remove it.', + managedStates: { enabled: 'Available', disabled: 'Plugin disabled', removed: 'Plugin removed', unavailable: 'Plugin unavailable' }, + managedReasons: { authentication_required: 'A dependency needs authentication.', target_changed: 'A dependency changed and needs configuration.', dependency_unavailable: 'A required tool or Skill is unavailable.' }, + managedUnavailable: 'This NPC cannot accept new work. Bound protagonists can still run independently.', + managePlugin: 'Manage plugin', + duplicate: 'Create custom copy', + duplicateHint: 'Copies are editable and no longer follow this definition. Tool and Skill references may still depend on the original plugin; credentials and Skill files are not copied.', enabled: 'Enabled', paused: 'Disabled', enabledHint: 'Disabling blocks new executions and preserves history.', @@ -163,6 +171,14 @@ export function buddyLabels(language: string) { kind: '类别', actorHint: '参与对话,可委派已绑定的 NPC。', npcHint: '通过委派调用的可复用专业定义。', + managedSource: (name: string) => `由插件 ${name} 管理`, + managedDefinitionHint: '定义由插件维护,可修改模型和启用选择。停用此 NPC 或前往插件管理移除来源。', + managedStates: { enabled: '可用', disabled: '插件已停用', removed: '插件已移除', unavailable: '插件不可用' }, + managedReasons: { authentication_required: '依赖项需要认证。', target_changed: '依赖项已变更,需要重新配置。', dependency_unavailable: '必需的工具或 Skill 不可用。' }, + managedUnavailable: '此 NPC 不接受新工作,已绑定的主角仍可独立运行。', + managePlugin: '管理插件', + duplicate: '创建自定义副本', + duplicateHint: '副本可编辑,不再跟随原定义更新或删除。工具和 Skill 引用仍可能依赖原插件,不复制凭据和 Skill 文件。', enabled: '启用', paused: '已停用', enabledHint: '停用后不再接受新执行,历史内容保留。', diff --git a/apps/buddy/src/modules/buddies/state/useBuddyActions.ts b/apps/buddy/src/modules/buddies/state/useBuddyActions.ts index dc375167a..9f651dc1d 100644 --- a/apps/buddy/src/modules/buddies/state/useBuddyActions.ts +++ b/apps/buddy/src/modules/buddies/state/useBuddyActions.ts @@ -36,7 +36,7 @@ export function useBuddyActions(buddy: Readonly>) { async function setEnabled(enabled: boolean) { const target = buddy.value - if (busy.value || !target || target.enabled === enabled || (isDefault.value && !enabled)) + if (busy.value || !target || (target.managed?.userEnabled ?? target.enabled) === enabled || (isDefault.value && !enabled)) return busy.value = true error.value = '' @@ -51,8 +51,26 @@ export function useBuddyActions(buddy: Readonly>) { finally { busy.value = false } } + async function duplicate() { + const target = buddy.value + if (busy.value || !target || target.deletedAt) + return + busy.value = true + error.value = '' + try { + const copy = await api.duplicate({ buddyId: target.id, expectedRevision: target.revision }) + await refresh() + await router.push(desktopRouteLocations.buddies(copy.id)) + } + catch (reason) { + error.value = resolveLocalChatErrorMessage(reason, language.value) + await refresh() + } + finally { busy.value = false } + } + function request(action: string) { - if (buddy.value && (action === 'delete' || action === 'resetProfile')) { + if (buddy.value && !buddy.value.managed && (action === 'delete' || action === 'resetProfile')) { error.value = '' confirmation.value = { action, buddy: buddy.value } } @@ -79,5 +97,5 @@ export function useBuddyActions(buddy: Readonly>) { finally { busy.value = false } } - return { busy, error, isDefault, isBuiltin, confirmation, setDefault, setEnabled, request, confirm } + return { busy, error, isDefault, isBuiltin, confirmation, setDefault, setEnabled, duplicate, request, confirm } } diff --git a/apps/buddy/src/modules/buddies/widgets/BuddyManagedNotice.vue b/apps/buddy/src/modules/buddies/widgets/BuddyManagedNotice.vue new file mode 100644 index 000000000..c75d258e5 --- /dev/null +++ b/apps/buddy/src/modules/buddies/widgets/BuddyManagedNotice.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/buddy/src/modules/buddies/widgets/BuddyNpcRecruitDialog.vue b/apps/buddy/src/modules/buddies/widgets/BuddyNpcRecruitDialog.vue index d95b39df8..a78b60e64 100644 --- a/apps/buddy/src/modules/buddies/widgets/BuddyNpcRecruitDialog.vue +++ b/apps/buddy/src/modules/buddies/widgets/BuddyNpcRecruitDialog.vue @@ -1,5 +1,6 @@