import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it, vi } from 'vitest' import { CapabilityService, type CapabilityCipher, type CapabilityServiceOptions } from './capability-service' import { BrowserProfileService, MemoryBrowserProfileStore } from './browser-profile-service' import { CapabilityDiagnostics } from './capability-diagnostics' const temporaryDirectories: string[] = [] const cipher: CapabilityCipher = { isAvailable: () => true, encrypt: (value) => Buffer.from(`encrypted:${value}`), decrypt: (value) => value.toString().replace(/^encrypted:/u, '') } class FailingBrowserProfileService extends BrowserProfileService { failNextAddAfterSave = false failNextRemoveAfterSave = false override async addReference( ...args: Parameters ): ReturnType { const result = await super.addReference(...args) if (this.failNextAddAfterSave) { this.failNextAddAfterSave = false throw new Error('Injected add reference failure') } return result } override async removeReference( ...args: Parameters ): ReturnType { const result = await super.removeReference(...args) if (this.failNextRemoveAfterSave) { this.failNextRemoveAfterSave = false throw new Error('Injected remove reference failure') } return result } } async function writeSkill( root: string, id: string, name: string, body = '仅用于离线测试。' ): Promise { const directory = join(root, id) await mkdir(directory, { recursive: true }) await writeFile( join(directory, 'SKILL.md'), [ '---', `id: ${id}`, `name: ${name}`, `description: ${name}的测试说明`, 'version: 1.0.0', 'tags:', ' - 测试', '---', '', `# ${name}`, '', body ].join('\n'), 'utf8' ) } async function createService( options: CapabilityServiceOptions = { platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ) } ): Promise<{ directory: string filePath: string builtinRoot: string importedRoot: string service: CapabilityService }> { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-capabilities-')) temporaryDirectories.push(directory) const filePath = join(directory, 'capabilities.json') const builtinRoot = join(directory, 'builtin') const importedRoot = join(directory, 'imported') await writeSkill(builtinRoot, 'document-writing', '文档写作') return { directory, filePath, builtinRoot, importedRoot, service: new CapabilityService( filePath, builtinRoot, importedRoot, cipher, options ) } } afterEach(async () => { delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) describe('CapabilityService', () => { it('memoizes concurrent loads and retries after a failed load', async () => { const initialStore = { load: vi.fn(async () => undefined), save: vi.fn(async () => undefined) } const created = await createService({ platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: new BrowserProfileService(initialStore) }) await Promise.all([ created.service.getComputerCapabilityStatus('host-browser-control'), created.service.getComputerCapabilityStatus('host-browser-control'), created.service.getComputerCapabilityStatus('host-browser-control') ]) expect(initialStore.load).toHaveBeenCalledOnce() let profileLoads = 0 const browserProfiles = new BrowserProfileService({ load: vi.fn(async () => { profileLoads += 1 if (profileLoads === 1) { throw new Error('Injected profile load failure') } return undefined }), save: vi.fn(async () => undefined) }) const retrying = new CapabilityService( created.filePath, created.builtinRoot, created.importedRoot, cipher, { platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles } ) await expect( Promise.all([ retrying.getComputerCapabilityStatus('host-browser-control'), retrying.getComputerCapabilityStatus('host-browser-control') ]) ).rejects.toThrow('Injected profile load failure') await expect( retrying.getComputerCapabilityStatus('host-browser-control') ).resolves.toEqual({ enabled: false, supported: true }) expect(profileLoads).toBe(2) }) it('reports safe enabled and supported capability status', async () => { const { service } = await createService() await expect( service.getComputerCapabilityStatus('host-browser-control') ).resolves.toEqual({ enabled: false, supported: true }) await service.setComputerCapabilityEnabled( 'host-browser-control', true ) await expect( service.getComputerCapabilityStatus('host-browser-control') ).resolves.toEqual({ enabled: true, supported: true }) }) it('discovers built-in skills and persists enablement and assignments', async () => { const { filePath, builtinRoot, importedRoot, service } = await createService() await expect(service.getSnapshot()).resolves.toMatchObject({ skills: [ { id: 'document-writing', source: 'builtin', enabled: true, assignments: ['model', 'opencode', 'continue'] } ] }) await service.setSkillEnabled('document-writing', false) await service.setSkillAssignments('document-writing', ['model']) const reloaded = new CapabilityService( filePath, builtinRoot, importedRoot, cipher ) await expect(reloaded.getSnapshot()).resolves.toMatchObject({ skills: [ { id: 'document-writing', enabled: false, assignments: ['model'] } ] }) await expect( reloaded.getSkillInstructions('continue', 10_000) ).resolves.toBe('') await reloaded.setSkillEnabled('document-writing', true) await expect( reloaded.getSkillInstructions('model', 10_000) ).resolves.toContain('仅用于离线测试') await expect( reloaded.getRuntimeSkillContext('model', 10_000) ).resolves.toMatchObject({ instructions: expect.stringContaining('仅用于离线测试'), packages: [ { id: 'document-writing', directory: join(builtinRoot, 'document-writing') } ] }) }) it('imports and removes a managed SKILL.md package', async () => { const { directory, service } = await createService() const packageRoot = join(directory, 'source-skill') await writeSkill(packageRoot, 'meeting-helper', '会议助手') const source = join(packageRoot, 'meeting-helper') await writeFile(join(source, 'template.txt'), 'template', 'utf8') const imported = await service.importSkill(source) expect(imported.skills).toContainEqual( expect.objectContaining({ id: 'meeting-helper', source: 'imported' }) ) const removed = await service.removeSkill('meeting-helper') expect(removed.skills).not.toContainEqual( expect.objectContaining({ id: 'meeting-helper' }) ) await expect( service.removeSkill('document-writing') ).rejects.toThrow('只能删除已导入') }) it('imports a standard SKILL.md that identifies itself by name', async () => { const { directory, service } = await createService() const source = join(directory, 'standard-source', 'summarize-diff') await mkdir(source, { recursive: true }) await writeFile( join(source, 'SKILL.md'), [ '---', 'name: summarize-diff', 'description: |', ' 概括暂存的改动。', ' 当用户需要待提交变更摘要时使用。', 'allowed-tools:', ' - Read', ' - Grep', 'compatibility: droid', '---', '', '# Summarize Diff', '', '仅用于离线测试。' ].join('\n'), 'utf8' ) const imported = await service.importSkill(source) expect(imported.skills).toContainEqual( expect.objectContaining({ id: 'summarize-diff', source: 'imported', description: '概括暂存的改动。 当用户需要待提交变更摘要时使用。' }) ) }) it('imports every Skill found under a suite directory', async () => { const { directory, service } = await createService() const suite = join(directory, 'suite', 'skills') await writeSkill(suite, 'alpha-skill', 'Alpha') await writeSkill(suite, 'beta-skill', 'Beta') const imported = await service.importSkill(join(directory, 'suite')) expect(imported.skills.map((skill) => skill.id)).toEqual( expect.arrayContaining(['alpha-skill', 'beta-skill']) ) }) it('reports a readable error when the selected directory has no SKILL.md', async () => { const { directory, service } = await createService() const empty = join(directory, 'empty-directory') await mkdir(empty, { recursive: true }) await expect(service.importSkill(empty)).rejects.toThrow( '没有找到 SKILL.md' ) }) it('exposes the skill directory and names skills dropped by the budget', async () => { const { builtinRoot, service } = await createService() await writeSkill( builtinRoot, 'oversized-skill', '超长技能', '超长技能说明。'.repeat(80) ) const instructions = await service.getSkillInstructions('model') expect(instructions).toContain(join(builtinRoot, 'document-writing')) expect(instructions).toContain(join(builtinRoot, 'oversized-skill')) const truncated = await service.getSkillInstructions('model', 200) expect(truncated).toContain('因超出注入上限未加载') expect(truncated).toContain('超长技能') const fullyTruncated = await service.getSkillInstructions('model', 1) expect(fullyTruncated).toContain('因超出注入上限未加载') expect(fullyTruncated).toContain('文档写作') expect(fullyTruncated).toContain('超长技能') }) it('imports a managed Skill from a ZIP package', async () => { const { directory, importedRoot, service } = await createService() const packageRoot = join(directory, 'zip-source') await writeSkill(packageRoot, 'meeting-helper', '会议助手') const skillMarkdown = await readFile( join(packageRoot, 'meeting-helper', 'SKILL.md') ) const archivePath = join(directory, 'meeting-helper.zip') await writeFile( archivePath, zipSync({ 'meeting-helper/SKILL.md': skillMarkdown, 'meeting-helper/template.txt': strToU8('template') }) ) const imported = await service.importSkill(archivePath) expect(imported.skills).toContainEqual( expect.objectContaining({ id: 'meeting-helper', source: 'imported' }) ) await expect( readFile( join(importedRoot, 'meeting-helper', 'template.txt'), 'utf8' ) ).resolves.toBe('template') }) it('rejects unsafe paths in a Skill ZIP package', async () => { const { directory, importedRoot, service } = await createService() const packageRoot = join(directory, 'unsafe-source') await writeSkill(packageRoot, 'unsafe-skill', '不安全 Skill') const skillMarkdown = await readFile( join(packageRoot, 'unsafe-skill', 'SKILL.md') ) const archivePath = join(directory, 'unsafe-skill.zip') await writeFile( archivePath, zipSync({ '../escape.txt': strToU8('escape'), 'unsafe-skill/SKILL.md': skillMarkdown }) ) await expect(service.importSkill(archivePath)).rejects.toThrow( 'Skill ZIP 包含不安全路径' ) await expect( readFile(join(importedRoot, 'escape.txt'), 'utf8') ).rejects.toThrow() }) it('encrypts remote MCP secrets and never returns them publicly', async () => { const { filePath, service } = await createService() const snapshot = await service.saveMcpServer(undefined, { name: 'Remote MCP', description: 'Remote test server', enabled: true, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', url: 'https://mcp.example.com/mcp' }) const server = snapshot.mcpServers[0] expect(server).toMatchObject({ name: 'Remote MCP', transport: 'http', secretConfigured: true }) expect(JSON.stringify(snapshot)).not.toContain('secret-token-value') expect(await readFile(filePath, 'utf8')).not.toContain( 'secret-token-value' ) if (!server) { throw new Error('Expected saved MCP server') } await expect( service.getResolvedMcpServer(server.id) ).resolves.toMatchObject({ secret: 'secret-token-value' }) }) it('stores stdio command and arguments as separate values', async () => { const { service } = await createService() const snapshot = await service.saveMcpServer(undefined, { name: 'Local MCP', description: '', enabled: true, assignments: ['model'], secret: { action: 'keep' }, transport: 'stdio', command: 'node', args: ['server.js', '--safe'] }) expect(snapshot.mcpServers[0]).toMatchObject({ transport: 'stdio', command: 'node', args: ['server.js', '--safe'] }) }) it('allows bearer tokens over the full IPv4 loopback range', async () => { const { service } = await createService() await expect( service.saveMcpServer(undefined, { name: 'Loopback MCP', description: '', enabled: true, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', url: 'http://127.0.0.2/mcp' }) ).resolves.toMatchObject({ mcpServers: [ expect.objectContaining({ name: 'Loopback MCP', url: 'http://127.0.0.2/mcp' }) ] }) }) it('allows bearer tokens over HTTP on any configured host', async () => { const { service } = await createService() const snapshot = await service.saveMcpServer(undefined, { name: 'Intranet MCP', description: '', enabled: true, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', url: 'http://mcp.internal/mcp' }) expect(snapshot).toMatchObject({ mcpServers: [ expect.objectContaining({ name: 'Intranet MCP', secretConfigured: true, url: 'http://mcp.internal/mcp' }) ] }) const server = snapshot.mcpServers[0] if (!server) { throw new Error('Expected saved intranet MCP server') } await expect( service.getResolvedMcpServer(server.id) ).resolves.toMatchObject({ secret: 'secret-token-value' }) }) it('allows public HTTP MCP servers with or without bearer tokens', async () => { const { service } = await createService() await expect( service.saveMcpServer(undefined, { name: 'Public plaintext MCP', description: '', enabled: true, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', url: 'http://mcp.example.com/mcp' }) ).resolves.toMatchObject({ mcpServers: [ expect.objectContaining({ url: 'http://mcp.example.com/mcp', secretConfigured: true }) ] }) await expect( service.saveMcpServer(undefined, { name: 'Public MCP without token', description: '', enabled: true, assignments: ['model'], secret: { action: 'clear' }, transport: 'http', url: 'http://mcp.example.com/no-token' }) ).resolves.toMatchObject({ mcpServers: expect.arrayContaining([ expect.objectContaining({ url: 'http://mcp.example.com/no-token', secretConfigured: false }) ]) }) }) it('rejects MCP assignments to Agent Runtimes', async () => { const { service } = await createService() await expect( service.saveMcpServer(undefined, { name: 'Agent MCP', description: '', enabled: true, assignments: ['opencode'], secret: { action: 'keep' }, transport: 'stdio', command: 'node', args: ['server.js'] }) ).rejects.toThrow('只能分配给直连模型') }) it('migrates legacy OpenCode MCP assignments to the direct model', async () => { const { filePath, builtinRoot, importedRoot } = await createService() await writeFile( filePath, JSON.stringify({ version: 1, skills: {}, mcpServers: [ { id: 'd2ef774b-146c-4467-a909-6feb112a9c2c', name: 'Legacy MCP', description: '', enabled: true, assignments: ['opencode'], transport: 'stdio', command: 'node', args: ['server.js'] } ] }), 'utf8' ) const service = new CapabilityService( filePath, builtinRoot, importedRoot, cipher ) await expect(service.getSnapshot()).resolves.toMatchObject({ mcpServers: [ expect.objectContaining({ assignments: ['model'] }) ] }) expect(await readFile(filePath, 'utf8')).toContain( '"assignments": [\n "model"' ) await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([]) await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1) }) it('migrates v1 to v2 without losing skills, MCP configuration, or encrypted secrets', async () => { const { filePath, builtinRoot, importedRoot } = await createService() const credential = Buffer.from( 'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}' ).toString('base64') await writeFile( filePath, JSON.stringify({ version: 1, skills: { 'document-writing': { enabled: false, assignments: ['model'] } }, mcpServers: [ { id: 'd2ef774b-146c-4467-a909-6feb112a9c2c', name: 'Preserved MCP', description: 'migration', enabled: true, assignments: ['model'], credential: { formatVersion: 1, scheme: 'electron-safe-storage', ciphertextBase64: credential }, transport: 'http', url: 'https://mcp.example.com/mcp' } ] }), 'utf8' ) const service = new CapabilityService( filePath, builtinRoot, importedRoot, cipher, { platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ) } ) await expect(service.getSnapshot()).resolves.toMatchObject({ skills: [ expect.objectContaining({ id: 'document-writing', enabled: false, assignments: ['model'] }) ], mcpServers: [ expect.objectContaining({ name: 'Preserved MCP', secretConfigured: true }) ], computerCapabilities: [ expect.objectContaining({ id: 'host-browser-control', enabled: false }), expect.objectContaining({ id: 'linux-desktop-control', enabled: false }) ] }) const persisted = await readFile(filePath, 'utf8') expect(persisted).toContain('"version": 2') expect(persisted).toContain(credential) expect(persisted).not.toContain('preserved-secret') }) it('gates enablement on the supported platform and architecture', async () => { const { service } = await createService({ platform: 'darwin', architecture: 'arm64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ) }) await expect( service.setComputerCapabilityEnabled( 'linux-desktop-control', true ) ).rejects.toThrow('不支持') const snapshot = await service.setComputerCapabilityEnabled( 'host-browser-control', true ) expect(snapshot.computerCapabilities).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'host-browser-control', enabled: true }) ]) ) }) it('maintains browser profile references and rejects unknown profiles', async () => { const { service } = await createService() await expect( service.setComputerCapabilityBrowserProfile( 'host-browser-control', 'b7f29e4c-1c4a-4aa0-ac58-5165451dde07' ) ).rejects.toThrow('不存在') const created = await service.createBrowserProfile('隔离工作配置') const profileId = created.browserProfiles?.profiles[0]?.id if (!profileId) { throw new Error('Expected browser profile') } await service.setComputerCapabilityBrowserProfile( 'host-browser-control', profileId ) await expect(service.removeBrowserProfile(profileId)).rejects.toThrow( 'Referenced' ) await service.setComputerCapabilityBrowserProfile( 'host-browser-control', null ) await expect(service.removeBrowserProfile(profileId)).resolves.toMatchObject( { browserProfiles: { profiles: [], defaultProfileId: null } } ) }) it('compensates browser profile references when add or capability persistence fails', async () => { const addProfiles = new FailingBrowserProfileService( new MemoryBrowserProfileStore() ) const addCase = await createService({ platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: addProfiles }) const addCreated = await addCase.service.createBrowserProfile( '添加失败配置' ) const addProfileId = addCreated.browserProfiles?.profiles[0]?.id if (!addProfileId) { throw new Error('Expected add-failure browser profile') } addProfiles.failNextAddAfterSave = true await expect( addCase.service.setComputerCapabilityBrowserProfile( 'host-browser-control', addProfileId ) ).rejects.toThrow('Injected add reference failure') await expect( addCase.service.removeBrowserProfile(addProfileId) ).resolves.toMatchObject({ browserProfiles: { profiles: [], defaultProfileId: null } }) const persistProfiles = new BrowserProfileService( new MemoryBrowserProfileStore() ) const persistCase = await createService({ platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: persistProfiles }) const persistCreated = await persistCase.service.createBrowserProfile('保存失败配置') const persistProfileId = persistCreated.browserProfiles?.profiles[0]?.id if (!persistProfileId) { throw new Error('Expected persistence-failure browser profile') } await mkdir(persistCase.filePath) await expect( persistCase.service.setComputerCapabilityBrowserProfile( 'host-browser-control', persistProfileId ) ).rejects.toThrow() await expect( persistCase.service.removeBrowserProfile(persistProfileId) ).resolves.toMatchObject({ browserProfiles: { profiles: [], defaultProfileId: null } }) }) it('rolls back capability and profile stores when old-reference removal fails', async () => { const browserProfiles = new FailingBrowserProfileService( new MemoryBrowserProfileStore() ) const { service } = await createService({ platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles }) const first = await service.createBrowserProfile('原配置') const firstId = first.browserProfiles?.profiles[0]?.id const second = await service.createBrowserProfile('新配置') const secondId = second.browserProfiles?.profiles[1]?.id if (!firstId || !secondId) { throw new Error('Expected two browser profiles') } await service.setComputerCapabilityBrowserProfile( 'host-browser-control', firstId ) browserProfiles.failNextRemoveAfterSave = true await expect( service.setComputerCapabilityBrowserProfile( 'host-browser-control', secondId ) ).rejects.toThrow('Injected remove reference failure') await expect(service.getSnapshot()).resolves.toMatchObject({ computerCapabilities: expect.arrayContaining([ expect.objectContaining({ id: 'host-browser-control', browserProfileId: firstId }) ]) }) await expect(service.removeBrowserProfile(secondId)).resolves.toBeDefined() await expect(service.removeBrowserProfile(firstId)).rejects.toThrow( 'Referenced' ) }) it('redacts injected diagnostics and never claims browser availability outside Electron', async () => { process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET = 'service-diagnostic-secret-value' const diagnostics = new CapabilityDiagnostics([ { id: 'browser-executable', run: async () => ({ status: 'available', summary: 'token=visible-token service-diagnostic-secret-value C:\\Users\\Alice\\browser' }) }, { id: 'managed-profile-root', run: async () => ({ status: 'available', summary: 'managed storage ready' }) } ]) const { service } = await createService({ platform: 'win32', architecture: 'x64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ), diagnostics }) await service.setComputerCapabilityEnabled('host-browser-control', true) const report = await service.diagnoseComputerCapability( 'host-browser-control' ) expect(report.status).toBe('available') expect(JSON.stringify(report)).not.toContain('visible-token') expect(JSON.stringify(report)).not.toContain( 'service-diagnostic-secret-value' ) const outsideElectron = await createService({ platform: 'win32', architecture: 'x64', electronTarget: false, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ), diagnostics }) await expect( outsideElectron.service.setComputerCapabilityEnabled( 'host-browser-control', true ) ).rejects.toThrow('诊断不可用') await expect( outsideElectron.service.getComputerCapabilityStatus( 'host-browser-control' ) ).resolves.toEqual({ enabled: false, supported: true }) delete process.env.GOODBUDDY_CAPABILITY_SERVICE_SECRET }) it('keeps Linux desktop unavailable without a registered native adapter', async () => { const { service } = await createService({ platform: 'linux', architecture: 'x64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ) }) await expect( service.setComputerCapabilityEnabled( 'linux-desktop-control', true ) ).rejects.toThrow('不支持') await expect( service.getComputerCapabilityStatus('linux-desktop-control') ).resolves.toEqual({ enabled: false, supported: false }) }) it('allows Linux desktop enablement with available injected diagnostics', async () => { const diagnostics = new CapabilityDiagnostics( ['linux-session', 'desktop-driver', 'desktop-permissions'].map( (id) => ({ id, run: async () => ({ status: 'available' as const, summary: `${id} ready` }) }) ) ) const { service } = await createService({ platform: 'linux', architecture: 'arm64', electronTarget: true, browserProfiles: new BrowserProfileService( new MemoryBrowserProfileStore() ), diagnostics, availableComputerCapabilityImplementations: [ 'managed-browser-driver', 'managed-linux-desktop-driver' ] }) await expect( service.setComputerCapabilityEnabled( 'linux-desktop-control', true ) ).resolves.toMatchObject({ computerCapabilities: expect.arrayContaining([ expect.objectContaining({ id: 'linux-desktop-control', enabled: true }) ]) }) }) })