feat: add natural language configuration tools
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
import {
|
||||
access,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApplicationSettingsStore } from './application-settings-store'
|
||||
import {
|
||||
BrowserProfileService,
|
||||
MemoryBrowserProfileStore
|
||||
} from './capabilities/browser-profile-service'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher
|
||||
} from './capabilities/capability-service'
|
||||
import { GoodBuddyConfigService } from './goodbuddy-config-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const cipher: CapabilityCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(value),
|
||||
decrypt: (value) => value.toString()
|
||||
}
|
||||
|
||||
async function writeSkill(
|
||||
root: string,
|
||||
id: string,
|
||||
body = 'Follow the user request.'
|
||||
): Promise<string> {
|
||||
const directory = join(root, id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(
|
||||
join(directory, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
`id: ${id}`,
|
||||
`name: ${id}`,
|
||||
`description: ${id} test skill`,
|
||||
'---',
|
||||
'',
|
||||
body
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createHarness(now = 1_000) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-config-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const builtinRoot = join(directory, 'builtin')
|
||||
const importedRoot = join(directory, 'imported')
|
||||
const workspace = join(directory, 'workspace')
|
||||
await mkdir(workspace, { recursive: true })
|
||||
await writeSkill(builtinRoot, 'built-in')
|
||||
const capabilities = new CapabilityService(
|
||||
join(directory, 'capabilities.json'),
|
||||
builtinRoot,
|
||||
importedRoot,
|
||||
cipher,
|
||||
{
|
||||
browserProfiles: new BrowserProfileService(
|
||||
new MemoryBrowserProfileStore()
|
||||
)
|
||||
}
|
||||
)
|
||||
const application = new ApplicationSettingsStore(
|
||||
join(directory, 'application.json')
|
||||
)
|
||||
const service = new GoodBuddyConfigService(application, capabilities, {
|
||||
now: () => now,
|
||||
planTtlMs: 100
|
||||
})
|
||||
return {
|
||||
directory,
|
||||
workspace,
|
||||
capabilities,
|
||||
application,
|
||||
service,
|
||||
setNow(value: number) {
|
||||
now = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('GoodBuddyConfigService', () => {
|
||||
it('returns only sanitized configuration and publishes common examples', async () => {
|
||||
const { service, capabilities } = await createHarness()
|
||||
await capabilities.saveMcpServer(undefined, {
|
||||
name: 'Secret MCP',
|
||||
description: '',
|
||||
enabled: false,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'never-return-this' },
|
||||
transport: 'http',
|
||||
url: 'https://mcp.example.com/tools'
|
||||
})
|
||||
|
||||
expect(service.getCapabilities()).toMatchObject({
|
||||
server: 'goodbuddy_config',
|
||||
applyRequiresApproval: true,
|
||||
operations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: 'skill.import',
|
||||
exampleRequest: expect.stringContaining('导入')
|
||||
})
|
||||
])
|
||||
})
|
||||
const snapshot = await service.getSnapshot()
|
||||
expect(snapshot.mcpServers[0]).toMatchObject({
|
||||
name: 'Secret MCP',
|
||||
secretConfigured: true,
|
||||
transport: 'http'
|
||||
})
|
||||
expect(JSON.stringify(snapshot)).not.toContain('never-return-this')
|
||||
expect(JSON.stringify(snapshot)).not.toContain(
|
||||
'https://mcp.example.com/tools'
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes one-shot plans to a request and rejects expired plans', async () => {
|
||||
const harness = await createHarness()
|
||||
const plan = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
const authorize = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-two',
|
||||
{ planId: plan.planId },
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
).rejects.toThrow('不属于当前请求')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
|
||||
const expiring = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
harness.setNow(1_101)
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: expiring.planId },
|
||||
new AbortController().signal,
|
||||
authorize
|
||||
)
|
||||
).rejects.toThrow('已过期')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a plan that expires while native approval is open', async () => {
|
||||
const harness = await createHarness()
|
||||
const plan = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: plan.planId },
|
||||
new AbortController().signal,
|
||||
async () => {
|
||||
harness.setNow(1_101)
|
||||
return true
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('确认期间已过期')
|
||||
await expect(harness.application.get()).resolves.toMatchObject({
|
||||
checkUpdatesOnStartup: true
|
||||
})
|
||||
})
|
||||
|
||||
it('requires approval and applies a plan only once', async () => {
|
||||
const harness = await createHarness()
|
||||
const deniedPlan = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: deniedPlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => false
|
||||
)
|
||||
).rejects.toThrow('用户拒绝')
|
||||
await expect(harness.application.get()).resolves.toMatchObject({
|
||||
checkUpdatesOnStartup: true
|
||||
})
|
||||
|
||||
const approvedPlan = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: approvedPlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'applied',
|
||||
appliedOperations: 1,
|
||||
reload: 'none'
|
||||
})
|
||||
await expect(harness.application.get()).resolves.toMatchObject({
|
||||
checkUpdatesOnStartup: false
|
||||
})
|
||||
expect(harness.service.takePendingReload('request-one')).toBe(
|
||||
'after-current-request'
|
||||
)
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: approvedPlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
).rejects.toThrow('不存在')
|
||||
})
|
||||
|
||||
it('imports the exact inspected Skill and requests a deferred reload', async () => {
|
||||
const harness = await createHarness()
|
||||
await writeSkill(harness.workspace, 'meeting-helper')
|
||||
const plan = await harness.service.plan(
|
||||
'request-one',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: './meeting-helper',
|
||||
enabled: false,
|
||||
assignments: ['model']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
expect(plan.operations[0]).toMatchObject({
|
||||
sourcePath: './meeting-helper'
|
||||
})
|
||||
await harness.service.apply(
|
||||
'request-one',
|
||||
{ planId: plan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
await expect(harness.capabilities.getSnapshot()).resolves.toMatchObject({
|
||||
skills: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'meeting-helper',
|
||||
source: 'imported',
|
||||
enabled: false,
|
||||
assignments: ['model']
|
||||
})
|
||||
])
|
||||
})
|
||||
expect(harness.service.takePendingReload('request-one')).toBe(
|
||||
'after-current-request'
|
||||
)
|
||||
expect(harness.service.takePendingReload('request-one')).toBe('none')
|
||||
|
||||
const secondSource = await writeSkill(
|
||||
harness.workspace,
|
||||
'changing-skill'
|
||||
)
|
||||
const changingPlan = await harness.service.plan(
|
||||
'request-two',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: secondSource,
|
||||
enabled: true,
|
||||
assignments: ['model']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
await writeFile(
|
||||
join(secondSource, 'extra.js'),
|
||||
'console.log("changed")',
|
||||
'utf8'
|
||||
)
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-two',
|
||||
{ planId: changingPlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
).rejects.toThrow('确认后已发生变化')
|
||||
})
|
||||
|
||||
it('reports partial application and still requests reload after a later failure', async () => {
|
||||
const harness = await createHarness()
|
||||
await writeSkill(harness.workspace, 'partial-skill')
|
||||
const plan = await harness.service.plan(
|
||||
'request-partial',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: './partial-skill',
|
||||
enabled: true,
|
||||
assignments: ['model']
|
||||
},
|
||||
{
|
||||
operation: 'application.update',
|
||||
updates: { checkUpdatesOnStartup: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
await writeFile(
|
||||
join(harness.workspace, 'partial-skill', 'changed.txt'),
|
||||
'changed',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-partial',
|
||||
{ planId: plan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
).rejects.toThrow('已发生变化')
|
||||
|
||||
const secondPlan = await harness.service.plan(
|
||||
'request-partial-two',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: './partial-skill',
|
||||
enabled: true,
|
||||
assignments: ['model']
|
||||
},
|
||||
{
|
||||
operation: 'skill.setEnabled',
|
||||
skillId: 'built-in',
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
const originalSetSkillEnabled = harness.capabilities.setSkillEnabled.bind(
|
||||
harness.capabilities
|
||||
)
|
||||
vi.spyOn(harness.capabilities, 'setSkillEnabled').mockImplementationOnce(
|
||||
async () => {
|
||||
throw new Error('later operation failed')
|
||||
}
|
||||
)
|
||||
const result = await harness.service.apply(
|
||||
'request-partial-two',
|
||||
{ planId: secondPlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
status: 'partially-applied',
|
||||
appliedOperations: 1,
|
||||
reload: 'after-current-request',
|
||||
error: 'later operation failed'
|
||||
})
|
||||
expect(harness.service.takePendingReload('request-partial-two')).toBe(
|
||||
'after-current-request'
|
||||
)
|
||||
vi.mocked(harness.capabilities.setSkillEnabled).mockImplementation(
|
||||
originalSetSkillEnabled
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects Skill paths outside the workspace', async () => {
|
||||
const harness = await createHarness()
|
||||
const outside = await writeSkill(harness.directory, 'outside-skill')
|
||||
await expect(
|
||||
harness.service.plan('request-one', harness.workspace, {
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: outside,
|
||||
enabled: true,
|
||||
assignments: ['model']
|
||||
}
|
||||
]
|
||||
})
|
||||
).rejects.toThrow('必须位于当前工作区')
|
||||
})
|
||||
|
||||
it('shows executable MCP details and invalidates hidden connection changes', async () => {
|
||||
const harness = await createHarness()
|
||||
const existing = await harness.capabilities.saveMcpServer(undefined, {
|
||||
name: 'Existing MCP',
|
||||
description: '',
|
||||
enabled: false,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'http',
|
||||
url: 'https://old.example.com/mcp'
|
||||
})
|
||||
const serverId = existing.mcpServers[0]!.id
|
||||
const addPlan = await harness.service.plan(
|
||||
'request-add',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'mcp.add',
|
||||
connection: {
|
||||
name: 'Local MCP',
|
||||
description: '',
|
||||
allowDynamicTools: false,
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js', '--safe']
|
||||
},
|
||||
enabled: false,
|
||||
assignments: ['model']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
expect(addPlan.steps[0]?.summary).toContain(
|
||||
'命令 "node" "server.js" "--safe"'
|
||||
)
|
||||
|
||||
const enablePlan = await harness.service.plan(
|
||||
'request-enable',
|
||||
harness.workspace,
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
operation: 'mcp.setEnabled',
|
||||
serverId,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
await harness.capabilities.saveMcpServer(serverId, {
|
||||
name: 'Existing MCP',
|
||||
description: '',
|
||||
enabled: false,
|
||||
allowDynamicTools: false,
|
||||
assignments: ['model'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'http',
|
||||
url: 'https://new.example.com/mcp'
|
||||
})
|
||||
await expect(
|
||||
harness.service.apply(
|
||||
'request-enable',
|
||||
{ planId: enablePlan.planId },
|
||||
new AbortController().signal,
|
||||
async () => true
|
||||
)
|
||||
).rejects.toThrow('确认前已发生变化')
|
||||
})
|
||||
|
||||
it('does not create persistent inspection artifacts while planning a ZIP Skill', async () => {
|
||||
const harness = await createHarness()
|
||||
const archivePath = join(harness.workspace, 'skill.zip')
|
||||
const { strToU8, zipSync } = await import('fflate')
|
||||
await writeFile(
|
||||
archivePath,
|
||||
Buffer.from(
|
||||
zipSync({
|
||||
'zip-helper/SKILL.md': strToU8(
|
||||
[
|
||||
'---',
|
||||
'id: zip-helper',
|
||||
'name: zip-helper',
|
||||
'description: ZIP helper',
|
||||
'---',
|
||||
'',
|
||||
'Help with ZIP files.'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await harness.service.plan('request-zip', harness.workspace, {
|
||||
operations: [
|
||||
{
|
||||
operation: 'skill.import',
|
||||
sourcePath: './skill.zip',
|
||||
enabled: false,
|
||||
assignments: []
|
||||
}
|
||||
]
|
||||
})
|
||||
await expect(
|
||||
access(join(harness.directory, 'imported'))
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user