Files
goodbuddy/src/main/goodbuddy-config-service.test.ts
T
mesalogo 02a6f5c4e7 fix: remove redundant confirmation for configuration changes
Configuration apply previously required a separate native confirmation even in Agent Runtime Execute mode, and failed in clients without an approval callback. Use existing work-mode and tool authorization instead, preserving Ask read-only access and request-scoped plan validation. Update regression tests, tool descriptions and documentation.

Validation: 267 focused tests passed; typecheck and changed-file lint passed. Full tests timed out with failures in other modules; repository lint reported errors in unchanged files.

Release note: Execute 模式下通过 Agent Runtime 修改 GoodBuddy 配置不再额外弹出确认框,Ask 模式仍保持只读。
2026-10-02 19:39:44 +08:00

573 lines
16 KiB
TypeScript

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('publishes persisted application changes from config apply through the settings event', async () => {
const { application, service, workspace } = await createHarness()
const changed = vi.fn()
const unsubscribe = application.onChanged(changed)
const current = await application.get()
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
const plan = await service.plan('settings-sync', workspace, {
operations: [{ operation: 'application.update', updates: { applicationNavigation, magicNoteCanvasPageCount: 8 } }],
})
expect(changed).not.toHaveBeenCalled()
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal)
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
expect((await service.getSnapshot()).application.magicNoteCanvasPageCount).toBe(8)
unsubscribe()
await application.update({ localInferenceEnabled: false })
expect(changed).toHaveBeenCalledOnce()
})
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',
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 }
}
]
}
)
await expect(
harness.service.apply(
'request-two',
{ planId: plan.planId },
new AbortController().signal
)
).rejects.toThrow('不属于当前请求')
expect((await harness.application.get()).checkUpdatesOnStartup).toBe(true)
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
)
).rejects.toThrow('已过期')
expect((await harness.application.get()).checkUpdatesOnStartup).toBe(true)
})
it('rejects a canceled apply and consumes its plan', async () => {
const harness = await createHarness()
const plan = await harness.service.plan(
'request-one',
harness.workspace,
{
operations: [
{
operation: 'application.update',
updates: { checkUpdatesOnStartup: false }
}
]
}
)
const controller = new AbortController()
controller.abort(new Error('Canceled config apply'))
await expect(
harness.service.apply(
'request-one',
{ planId: plan.planId },
controller.signal
)
).rejects.toThrow('Canceled config apply')
await expect(harness.application.get()).resolves.toMatchObject({
checkUpdatesOnStartup: true
})
await expect(harness.service.apply(
'request-one', { planId: plan.planId }, new AbortController().signal
)).rejects.toThrow('不存在')
})
it('checks expiry after waiting for an earlier apply', async () => {
const harness = await createHarness()
const input = { operations: [{
operation: 'application.update', updates: { checkUpdatesOnStartup: false }
}] }
const first = await harness.service.plan('first', harness.workspace, input)
const second = await harness.service.plan('second', harness.workspace, input)
let release!: () => void
const blocked = new Promise<void>(resolve => { release = resolve })
const update = harness.application.update.bind(harness.application)
const updating = vi.spyOn(harness.application, 'update').mockImplementationOnce(async updates => {
await blocked
return update(updates)
})
const applying = harness.service.apply('first', { planId: first.planId }, new AbortController().signal)
await vi.waitFor(() => expect(updating).toHaveBeenCalledOnce())
const expired = expect(harness.service.apply(
'second', { planId: second.planId }, new AbortController().signal
)).rejects.toThrow('已过期')
harness.setNow(1_101)
release()
await applying
await expired
expect(updating).toHaveBeenCalledOnce()
})
it('applies without an authorizer and consumes the plan only once', 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
)
).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: plan.planId },
new AbortController().signal
)
).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
)
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
)
).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
)
).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
)
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
)
).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()
})
})