Files
goodbuddy/src/main/goodbuddy-config-service.ts
T

604 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomUUID } from 'node:crypto'
import { isAbsolute, resolve } from 'node:path'
import {
goodbuddyConfigApplyInputSchema,
goodbuddyConfigApplyOutputSchema,
goodbuddyConfigCapabilities,
goodbuddyConfigCapabilitiesInputSchema,
goodbuddyConfigGetInputSchema,
goodbuddyConfigGetOutputSchema,
goodbuddyConfigOperationRegistry,
goodbuddyConfigPlanInputSchema,
goodbuddyConfigPlanOutputSchema,
type GoodBuddyConfigApplyOutput,
type GoodBuddyConfigOperation,
type GoodBuddyConfigPlanOutput,
type GoodBuddyConfigPlanStep,
type GoodBuddyConfigReload,
type GoodBuddyConfigRisk,
type GoodBuddyConfigSnapshot
} from '../shared/goodbuddy-config-contracts'
import type { ApplicationSettingsStore } from './application-settings-store'
import type {
CapabilityService,
SkillImportInspection
} from './capabilities/capability-service'
import {
getCanonicalWorkspace,
isPathInside
} from './workspace-file-access'
const DEFAULT_PLAN_TTL_MS = 5 * 60_000
const MAX_PLAN_TTL_MS = 10 * 60_000
const MAX_ACTIVE_PLANS = 32
type Plan = {
requestId: string
expiresAt: number
output: GoodBuddyConfigPlanOutput
operations: GoodBuddyConfigOperation[]
skillImports: Map<number, SkillImportInspection>
stateDigest: string
}
export type GoodBuddyConfigApplyEvent = {
requestId: string
planId: string
summary: string
risk: GoodBuddyConfigRisk
reload: GoodBuddyConfigReload
destructive: boolean
}
export type GoodBuddyConfigServiceOptions = {
now?: () => number
planTtlMs?: number
}
export type GoodBuddyConfigApplyAuthorizer = (
event: GoodBuddyConfigApplyEvent,
signal: AbortSignal
) => Promise<boolean>
function maximumRisk(
risks: readonly GoodBuddyConfigRisk[]
): GoodBuddyConfigRisk {
if (risks.includes('high')) {
return 'high'
}
return risks.includes('medium') ? 'medium' : 'low'
}
function maximumReload(
reloads: readonly GoodBuddyConfigReload[]
): GoodBuddyConfigReload {
return reloads.includes('after-current-request')
? 'after-current-request'
: 'none'
}
function toMcpInput(
operation: Extract<
GoodBuddyConfigOperation,
{ operation: 'mcp.add' | 'mcp.update' }
>,
current?: GoodBuddyConfigSnapshot['mcpServers'][number]
) {
return {
...operation.connection,
enabled:
operation.operation === 'mcp.add'
? operation.enabled
: current?.enabled ?? false,
assignments:
operation.operation === 'mcp.add'
? operation.assignments
: current?.assignments ?? [],
secret: { action: 'keep' as const }
}
}
function stableSnapshotDigest(
snapshot: GoodBuddyConfigSnapshot,
capabilityDigest: string
): string {
return createHash('sha256')
.update(JSON.stringify({ snapshot, capabilityDigest }))
.digest('hex')
}
function quoteApprovalValue(value: string, maximum = 1_000): string {
return JSON.stringify(value.slice(0, maximum))
}
function boundedApplyError(error: unknown): string {
return (
(error instanceof Error ? error.message : '配置操作失败')
.trim()
.slice(0, 2_000) || '配置操作失败'
)
}
export class GoodBuddyConfigService {
private readonly now: () => number
private readonly planTtlMs: number
private readonly plans = new Map<string, Plan>()
private readonly pendingReloads = new Map<
string,
GoodBuddyConfigReload
>()
private applyQueue: Promise<void> = Promise.resolve()
constructor(
private readonly applicationSettingsStore: ApplicationSettingsStore,
private readonly capabilityService: CapabilityService,
options: GoodBuddyConfigServiceOptions = {}
) {
this.now = options.now ?? Date.now
const ttl = options.planTtlMs ?? DEFAULT_PLAN_TTL_MS
if (
!Number.isSafeInteger(ttl) ||
ttl < 1 ||
ttl > MAX_PLAN_TTL_MS
) {
throw new RangeError('GoodBuddy 配置计划有效期无效')
}
this.planTtlMs = ttl
}
getCapabilities(input: unknown = {}): typeof goodbuddyConfigCapabilities {
goodbuddyConfigCapabilitiesInputSchema.parse(input)
return goodbuddyConfigCapabilities
}
async getSnapshot(input: unknown = {}): Promise<GoodBuddyConfigSnapshot> {
goodbuddyConfigGetInputSchema.parse(input)
const [application, capabilities] = await Promise.all([
this.applicationSettingsStore.get(),
this.capabilityService.getSnapshot()
])
return goodbuddyConfigGetOutputSchema.parse({
application,
skills: capabilities.skills,
mcpServers: capabilities.mcpServers.map((server) => ({
id: server.id,
name: server.name,
description: server.description,
enabled: server.enabled,
allowDynamicTools: server.allowDynamicTools,
assignments: server.assignments,
secretConfigured: server.secretConfigured,
transport: server.transport
}))
})
}
private prunePlans(): void {
const now = this.now()
for (const [planId, plan] of this.plans) {
if (plan.expiresAt <= now) {
this.plans.delete(planId)
}
}
while (this.plans.size >= MAX_ACTIVE_PLANS) {
const oldestPlanId = this.plans.keys().next().value
if (typeof oldestPlanId !== 'string') {
break
}
this.plans.delete(oldestPlanId)
}
}
private async resolveSkillPath(
workspacePath: string,
inputPath: string
): Promise<string> {
const workspace = await getCanonicalWorkspace(
workspacePath,
'GoodBuddy 配置工作区不是目录'
)
const candidate = isAbsolute(inputPath)
? resolve(inputPath)
: resolve(workspace, inputPath)
if (!isPathInside(workspace, candidate)) {
throw new Error('Skill 导入路径必须位于当前工作区')
}
const inspection =
await this.capabilityService.inspectSkillImport(candidate)
if (!isPathInside(workspace, inspection.sourcePath)) {
throw new Error('Skill 导入路径不能通过符号链接超出当前工作区')
}
return inspection.sourcePath
}
private ensureSkill(
snapshot: GoodBuddyConfigSnapshot,
skillId: string,
removable = false
): void {
const skill = snapshot.skills.find((item) => item.id === skillId)
if (!skill) {
throw new Error(`Skill 不存在:${skillId}`)
}
if (removable && skill.source !== 'imported') {
throw new Error(`只能删除已导入的 Skill:${skillId}`)
}
}
private ensureMcp(
snapshot: GoodBuddyConfigSnapshot,
serverId: string
): GoodBuddyConfigSnapshot['mcpServers'][number] {
const server = snapshot.mcpServers.find((item) => item.id === serverId)
if (!server) {
throw new Error(`MCP Server 不存在:${serverId}`)
}
return server
}
async plan(
requestId: string,
workspacePath: string,
input: unknown
): Promise<GoodBuddyConfigPlanOutput> {
const parsed = goodbuddyConfigPlanInputSchema.parse(input)
const snapshot = await this.getSnapshot()
const capabilityDigest =
await this.capabilityService.getConfigurationDigest()
const skillImports = new Map<number, SkillImportInspection>()
const steps: GoodBuddyConfigPlanStep[] = []
const normalizedOperations: GoodBuddyConfigOperation[] = []
const introducedSkillIds = new Set<string>()
for (const [index, operation] of parsed.operations.entries()) {
let normalized = operation
let summary: string =
goodbuddyConfigOperationRegistry[operation.operation].summary
switch (operation.operation) {
case 'application.update':
summary = `更新应用偏好:${Object.keys(operation.updates).join('、')}`
break
case 'skill.import': {
const sourcePath = await this.resolveSkillPath(
workspacePath,
operation.sourcePath
)
const inspection =
await this.capabilityService.inspectSkillImport(sourcePath)
for (const skill of inspection.skills) {
if (introducedSkillIds.has(skill.id)) {
throw new Error(`计划包含重复的 Skill:${skill.id}`)
}
introducedSkillIds.add(skill.id)
}
normalized = { ...operation, sourcePath }
skillImports.set(index, inspection)
summary = `从 ${quoteApprovalValue(operation.sourcePath)} 导入 ${inspection.skills
.map((skill) => skill.name)
.join('、')},${
operation.enabled ? '启用' : '保持禁用'
},分配给 ${
operation.assignments.length > 0
? operation.assignments.join('、')
: '无 Runtime'
}`
break
}
case 'skill.setEnabled':
this.ensureSkill(snapshot, operation.skillId)
summary = `${
operation.enabled ? '启用' : '禁用'
} Skill「${operation.skillId}」`
break
case 'skill.setAssignments':
this.ensureSkill(snapshot, operation.skillId)
summary = `设置 Skill「${operation.skillId}」的 Runtime 分配`
break
case 'skill.remove':
this.ensureSkill(snapshot, operation.skillId, true)
summary = `永久删除已导入 Skill「${operation.skillId}」`
break
case 'mcp.add':
summary = `添加${
operation.connection.transport === 'stdio'
? '可启动本地程序的'
: '远程'
} MCP Server「${operation.connection.name}」:${
operation.connection.transport === 'stdio'
? `命令 ${[
quoteApprovalValue(operation.connection.command),
...operation.connection.args.map((argument) =>
quoteApprovalValue(argument)
)
].join(' ')}`
: `地址 ${quoteApprovalValue(operation.connection.url, 2_048)}`
}`
break
case 'mcp.update': {
const current = this.ensureMcp(snapshot, operation.serverId)
if (
current.secretConfigured &&
current.transport !== 'stdio' &&
operation.connection.transport !== 'stdio'
) {
throw new Error(
'带访问令牌的 MCP 连接不能通过自然语言修改,请使用原生设置界面'
)
}
summary = `修改 MCP Server「${current.name}」的公开连接设置:${
operation.connection.transport === 'stdio'
? `命令 ${[
quoteApprovalValue(operation.connection.command),
...operation.connection.args.map((argument) =>
quoteApprovalValue(argument)
)
].join(' ')}`
: `地址 ${quoteApprovalValue(operation.connection.url, 2_048)}`
}`
break
}
case 'mcp.setEnabled': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `${
operation.enabled ? '启用' : '禁用'
} MCP Server「${current.name}」`
break
}
case 'mcp.setAssignments': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `设置 MCP Server「${current.name}」的 Runtime 分配`
break
}
case 'mcp.remove': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `永久删除 MCP Server「${current.name}」`
break
}
}
normalizedOperations.push(normalized)
const descriptor =
goodbuddyConfigOperationRegistry[operation.operation]
steps.push({
index,
operation: operation.operation,
summary,
risk: descriptor.risk,
reload: descriptor.reload,
destructive: descriptor.destructive
})
}
this.prunePlans()
const planId = randomUUID()
const expiresAt = this.now() + this.planTtlMs
const output = goodbuddyConfigPlanOutputSchema.parse({
planId,
expiresAt: new Date(expiresAt).toISOString(),
operations: parsed.operations,
steps,
overallRisk: maximumRisk(steps.map((step) => step.risk)),
reload: maximumReload(steps.map((step) => step.reload)),
requiresApproval: true
})
this.plans.set(planId, {
requestId,
expiresAt,
output,
operations: normalizedOperations,
skillImports,
stateDigest: stableSnapshotDigest(snapshot, capabilityDigest)
})
return output
}
private async applyOperation(
operation: GoodBuddyConfigOperation,
skillInspection: SkillImportInspection | undefined
): Promise<void> {
switch (operation.operation) {
case 'application.update':
await this.applicationSettingsStore.update(operation.updates)
return
case 'skill.import': {
if (!skillInspection) {
throw new Error('Skill 导入计划缺少校验信息')
}
await this.capabilityService.importSkill(
operation.sourcePath,
skillInspection.digest,
{
enabled: operation.enabled,
assignments: operation.assignments
}
)
return
}
case 'skill.setEnabled':
await this.capabilityService.setSkillEnabled(
operation.skillId,
operation.enabled
)
return
case 'skill.setAssignments':
await this.capabilityService.setSkillAssignments(
operation.skillId,
operation.assignments
)
return
case 'skill.remove':
await this.capabilityService.removeSkill(operation.skillId)
return
case 'mcp.add':
await this.capabilityService.saveMcpServer(
undefined,
toMcpInput(operation)
)
return
case 'mcp.update': {
const current = (await this.getSnapshot()).mcpServers.find(
(server) => server.id === operation.serverId
)
if (!current) {
throw new Error('MCP Server 不存在')
}
await this.capabilityService.saveMcpServer(
operation.serverId,
toMcpInput(operation, current)
)
return
}
case 'mcp.setEnabled':
case 'mcp.setAssignments': {
const current = (await this.capabilityService.getSnapshot()).mcpServers
.find((server) => server.id === operation.serverId)
if (!current) {
throw new Error('MCP Server 不存在')
}
await this.capabilityService.saveMcpServer(operation.serverId, {
...('url' in current
? { transport: current.transport, url: current.url }
: {
transport: 'stdio' as const,
command: current.command,
args: current.args
}),
name: current.name,
description: current.description,
allowDynamicTools: current.allowDynamicTools,
enabled:
operation.operation === 'mcp.setEnabled'
? operation.enabled
: current.enabled,
assignments:
operation.operation === 'mcp.setAssignments'
? operation.assignments
: current.assignments,
secret: { action: 'keep' }
})
return
}
case 'mcp.remove':
await this.capabilityService.removeMcpServer(operation.serverId)
}
}
apply(
requestId: string,
input: unknown,
signal: AbortSignal,
authorize: GoodBuddyConfigApplyAuthorizer | undefined
): Promise<GoodBuddyConfigApplyOutput> {
const parsed = goodbuddyConfigApplyInputSchema.parse(input)
const operation = this.applyQueue.then(async () => {
this.prunePlans()
const plan = this.plans.get(parsed.planId)
if (
!plan ||
plan.requestId !== requestId ||
plan.expiresAt <= this.now()
) {
this.plans.delete(parsed.planId)
throw new Error('GoodBuddy 配置计划不存在、已过期或不属于当前请求')
}
this.plans.delete(parsed.planId)
signal.throwIfAborted()
const approved =
(await authorize?.(
{
requestId,
planId: parsed.planId,
summary: plan.output.steps
.map((step) => `${step.index + 1}. ${step.summary}`)
.join('\n'),
risk: plan.output.overallRisk,
reload: plan.output.reload,
destructive: plan.output.steps.some(
(step) => step.destructive
)
},
signal
)) ?? false
if (!approved) {
throw new Error('用户拒绝了 GoodBuddy 配置变更')
}
signal.throwIfAborted()
if (plan.expiresAt <= this.now()) {
throw new Error('GoodBuddy 配置计划在确认期间已过期,请重新生成计划')
}
const currentSnapshot = await this.getSnapshot()
const currentCapabilityDigest =
await this.capabilityService.getConfigurationDigest()
if (
stableSnapshotDigest(
currentSnapshot,
currentCapabilityDigest
) !== plan.stateDigest
) {
throw new Error('GoodBuddy 配置在确认前已发生变化,请重新生成计划')
}
let appliedOperations = 0
for (const [index, plannedOperation] of plan.operations.entries()) {
try {
signal.throwIfAborted()
await this.applyOperation(
plannedOperation,
plan.skillImports.get(index)
)
appliedOperations += 1
} catch (error) {
if (appliedOperations === 0) {
throw error
}
this.pendingReloads.set(requestId, 'after-current-request')
return goodbuddyConfigApplyOutputSchema.parse({
planId: parsed.planId,
status: 'partially-applied',
appliedOperations,
reload: plan.output.reload,
snapshot: await this.getSnapshot(),
error: boundedApplyError(error)
})
}
}
const output = goodbuddyConfigApplyOutputSchema.parse({
planId: parsed.planId,
status: 'applied',
appliedOperations,
reload: plan.output.reload,
snapshot: await this.getSnapshot()
})
this.pendingReloads.set(
requestId,
appliedOperations > 0
? 'after-current-request'
: plan.output.reload
)
return output
})
this.applyQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
revokeRequest(requestId: string): void {
for (const [planId, plan] of this.plans) {
if (plan.requestId === requestId) {
this.plans.delete(planId)
}
}
}
takePendingReload(requestId: string): GoodBuddyConfigReload {
const reload = this.pendingReloads.get(requestId) ?? 'none'
this.pendingReloads.delete(requestId)
return reload
}
clear(): void {
this.plans.clear()
this.pendingReloads.clear()
}
}