feat: add persistent desktop assistant workspace
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
698a15ad14
commit
6ef1795b81
@@ -0,0 +1,212 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher
|
||||
} from './capability-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
const cipher: CapabilityCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(`encrypted:${value}`),
|
||||
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
|
||||
}
|
||||
|
||||
async function writeSkill(
|
||||
root: string,
|
||||
id: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
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}`,
|
||||
'',
|
||||
'仅用于离线测试。'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
async function createService(): 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('CapabilityService', () => {
|
||||
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('仅用于离线测试')
|
||||
})
|
||||
|
||||
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('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: ['opencode'],
|
||||
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: ['opencode'],
|
||||
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('never sends a bearer token over non-loopback HTTP', async () => {
|
||||
const { service } = await createService()
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Unsafe remote',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'http://mcp.example.com/mcp'
|
||||
})
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
capabilityAssignmentsSchema,
|
||||
mcpServerIdSchema,
|
||||
mcpServerInputSchema,
|
||||
mcpServerSummarySchema,
|
||||
skillIdSchema,
|
||||
skillSummarySchema,
|
||||
type CapabilityAssignments,
|
||||
type CapabilitySnapshot,
|
||||
type McpServerInput,
|
||||
type McpServerSummary,
|
||||
type RuntimeTarget,
|
||||
type SkillSummary
|
||||
} from '../../shared/capability-contracts'
|
||||
|
||||
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_FILES = 128
|
||||
const MAX_SKILL_DEPTH = 6
|
||||
|
||||
const skillMetadataSchema = z
|
||||
.object({
|
||||
id: skillIdSchema,
|
||||
name: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
version: z.string().trim().min(1).max(32).optional(),
|
||||
tags: z.array(z.string().trim().min(1).max(32)).max(12).default([])
|
||||
})
|
||||
.strict()
|
||||
|
||||
const skillStateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
assignments: capabilityAssignmentsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const encryptedSecretSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
ciphertextBase64: z.string()
|
||||
})
|
||||
.optional()
|
||||
|
||||
const storedMcpCommonShape = {
|
||||
id: mcpServerIdSchema,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
enabled: z.boolean(),
|
||||
assignments: capabilityAssignmentsSchema,
|
||||
credential: encryptedSecretSchema
|
||||
}
|
||||
|
||||
const storedMcpServerSchema = z.discriminatedUnion('transport', [
|
||||
z
|
||||
.object({
|
||||
...storedMcpCommonShape,
|
||||
transport: z.literal('stdio'),
|
||||
command: z.string(),
|
||||
args: z.array(z.string())
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...storedMcpCommonShape,
|
||||
transport: z.literal('http'),
|
||||
url: z.string()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...storedMcpCommonShape,
|
||||
transport: z.literal('sse'),
|
||||
url: z.string()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const storedCapabilitiesSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
skills: z.record(skillIdSchema, skillStateSchema),
|
||||
mcpServers: z.array(storedMcpServerSchema).max(64)
|
||||
})
|
||||
.strict()
|
||||
|
||||
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
|
||||
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
|
||||
|
||||
const secretPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
serverId: mcpServerIdSchema,
|
||||
secret: z.string()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type CapabilityCipher = {
|
||||
isAvailable: () => boolean
|
||||
encrypt: (value: string) => Buffer
|
||||
decrypt: (value: Buffer) => string
|
||||
}
|
||||
|
||||
export type ResolvedMcpServer = McpServerSummary & {
|
||||
secret?: string
|
||||
}
|
||||
|
||||
function defaultSkillState(): z.infer<typeof skillStateSchema> {
|
||||
return {
|
||||
enabled: true,
|
||||
assignments: ['model', 'opencode', 'continue']
|
||||
}
|
||||
}
|
||||
|
||||
async function readSkill(
|
||||
directoryPath: string,
|
||||
source: SkillSummary['source'],
|
||||
expectedId = basename(directoryPath)
|
||||
): Promise<Omit<SkillSummary, 'enabled' | 'assignments'>> {
|
||||
const filePath = join(directoryPath, 'SKILL.md')
|
||||
const file = await stat(filePath)
|
||||
if (!file.isFile() || file.size > MAX_SKILL_FILE_BYTES) {
|
||||
throw new Error(`${basename(directoryPath)} 的 SKILL.md 无效或过大`)
|
||||
}
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content)
|
||||
if (!match?.[1] || !match[2]?.trim()) {
|
||||
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
|
||||
}
|
||||
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
|
||||
if (metadata.id !== expectedId) {
|
||||
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
|
||||
}
|
||||
return skillSummarySchema
|
||||
.omit({ enabled: true, assignments: true })
|
||||
.parse({
|
||||
...metadata,
|
||||
source,
|
||||
digest: createHash('sha256').update(content).digest('hex')
|
||||
})
|
||||
}
|
||||
|
||||
async function listSkills(
|
||||
root: string,
|
||||
source: SkillSummary['source']
|
||||
): Promise<Array<Omit<SkillSummary, 'enabled' | 'assignments'>>> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() && !entry.name.startsWith('.')
|
||||
)
|
||||
.map((entry) => readSkill(join(root, entry.name), source))
|
||||
)
|
||||
}
|
||||
|
||||
async function copySkillPackage(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
): Promise<void> {
|
||||
let fileCount = 0
|
||||
let totalBytes = 0
|
||||
|
||||
const copyDirectory = async (
|
||||
source: string,
|
||||
target: string,
|
||||
depth: number
|
||||
): Promise<void> => {
|
||||
if (depth > MAX_SKILL_DEPTH) {
|
||||
throw new Error('Skill 目录层级超过安全限制')
|
||||
}
|
||||
await mkdir(target, { recursive: true })
|
||||
const entries = await readdir(source, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const sourcePath = join(source, entry.name)
|
||||
const targetPath = join(target, entry.name)
|
||||
const details = await lstat(sourcePath)
|
||||
if (details.isSymbolicLink()) {
|
||||
throw new Error('Skill 包不能包含符号链接')
|
||||
}
|
||||
if (details.isDirectory()) {
|
||||
await copyDirectory(sourcePath, targetPath, depth + 1)
|
||||
continue
|
||||
}
|
||||
if (!details.isFile()) {
|
||||
throw new Error('Skill 包只能包含普通文件和目录')
|
||||
}
|
||||
fileCount += 1
|
||||
totalBytes += details.size
|
||||
if (
|
||||
fileCount > MAX_SKILL_PACKAGE_FILES ||
|
||||
details.size > MAX_SKILL_FILE_BYTES ||
|
||||
totalBytes > MAX_SKILL_PACKAGE_BYTES
|
||||
) {
|
||||
throw new Error('Skill 包大小或文件数量超过安全限制')
|
||||
}
|
||||
await writeFile(targetPath, await readFile(sourcePath), {
|
||||
mode: 0o600
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await copyDirectory(sourceRoot, targetRoot, 0)
|
||||
}
|
||||
|
||||
export class CapabilityService {
|
||||
private state?: StoredCapabilities
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly builtinSkillsRoot: string,
|
||||
private readonly importedSkillsRoot: string,
|
||||
private readonly cipher: CapabilityCipher
|
||||
) {}
|
||||
|
||||
private async load(): Promise<StoredCapabilities> {
|
||||
if (this.state) {
|
||||
return this.state
|
||||
}
|
||||
try {
|
||||
this.state = storedCapabilitiesSchema.parse(
|
||||
JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
this.state = { version: 1, skills: {}, mcpServers: [] }
|
||||
} else {
|
||||
await rename(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${Date.now()}`
|
||||
).catch(() => undefined)
|
||||
this.state = { version: 1, skills: {}, mcpServers: [] }
|
||||
}
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
private queue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.updateQueue.then(operation)
|
||||
this.updateQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private async persist(state: StoredCapabilities): Promise<void> {
|
||||
const validated = storedCapabilitiesSchema.parse(state)
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(validated, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
this.state = validated
|
||||
}
|
||||
|
||||
private async getSkillCatalog(): Promise<
|
||||
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
|
||||
> {
|
||||
const [builtins, imported] = await Promise.all([
|
||||
listSkills(this.builtinSkillsRoot, 'builtin'),
|
||||
listSkills(this.importedSkillsRoot, 'imported')
|
||||
])
|
||||
const builtinIds = new Set(builtins.map((skill) => skill.id))
|
||||
const catalog = [
|
||||
...builtins,
|
||||
...imported.filter((skill) => !builtinIds.has(skill.id))
|
||||
]
|
||||
if (catalog.length > 256) {
|
||||
throw new Error('Skill 数量超过 256 个安全限制')
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
private toMcpSummary(server: StoredMcpServer): McpServerSummary {
|
||||
const { credential, ...configuration } = server
|
||||
return mcpServerSummarySchema.parse({
|
||||
...configuration,
|
||||
secretConfigured: Boolean(credential)
|
||||
})
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<CapabilitySnapshot> {
|
||||
const [state, catalog] = await Promise.all([
|
||||
this.load(),
|
||||
this.getSkillCatalog()
|
||||
])
|
||||
return {
|
||||
skills: catalog
|
||||
.map((skill) => ({
|
||||
...skill,
|
||||
...(state.skills[skill.id] ?? defaultSkillState())
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.source === right.source
|
||||
? left.name.localeCompare(right.name, 'zh-CN')
|
||||
: left.source === 'builtin'
|
||||
? -1
|
||||
: 1
|
||||
),
|
||||
mcpServers: state.mcpServers.map((server) =>
|
||||
this.toMcpSummary(server)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const canonicalSource = await realpath(sourcePath)
|
||||
if (!(await stat(canonicalSource)).isDirectory()) {
|
||||
throw new Error('所选 Skill 路径不是目录')
|
||||
}
|
||||
const skill = await readSkill(canonicalSource, 'imported')
|
||||
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
|
||||
if (builtins.some((item) => item.id === skill.id)) {
|
||||
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
|
||||
}
|
||||
const targetPath = join(this.importedSkillsRoot, skill.id)
|
||||
if (
|
||||
await stat(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
throw new Error('同名 Skill 已导入,请先删除后重试')
|
||||
}
|
||||
await mkdir(this.importedSkillsRoot, { recursive: true })
|
||||
const temporaryPath = join(
|
||||
this.importedSkillsRoot,
|
||||
`.import-${randomUUID()}`
|
||||
)
|
||||
try {
|
||||
await copySkillPackage(canonicalSource, temporaryPath)
|
||||
await readSkill(temporaryPath, 'imported', skill.id)
|
||||
await rename(temporaryPath, targetPath)
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[skill.id]: defaultSkillState()
|
||||
}
|
||||
})
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
removeSkill(skillId: string): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const id = skillIdSchema.parse(skillId)
|
||||
const imported = await listSkills(this.importedSkillsRoot, 'imported')
|
||||
if (!imported.some((skill) => skill.id === id)) {
|
||||
throw new Error('只能删除已导入的 Skill')
|
||||
}
|
||||
await rm(join(this.importedSkillsRoot, id), {
|
||||
recursive: true,
|
||||
force: false
|
||||
})
|
||||
const state = await this.load()
|
||||
const skills = { ...state.skills }
|
||||
delete skills[id]
|
||||
await this.persist({ ...state, skills })
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
setSkillEnabled(
|
||||
skillId: string,
|
||||
enabled: boolean
|
||||
): Promise<CapabilitySnapshot> {
|
||||
return this.updateSkillState(skillId, { enabled })
|
||||
}
|
||||
|
||||
setSkillAssignments(
|
||||
skillId: string,
|
||||
assignments: CapabilityAssignments
|
||||
): Promise<CapabilitySnapshot> {
|
||||
return this.updateSkillState(skillId, {
|
||||
assignments: capabilityAssignmentsSchema.parse(assignments)
|
||||
})
|
||||
}
|
||||
|
||||
private updateSkillState(
|
||||
skillId: string,
|
||||
update: Partial<z.infer<typeof skillStateSchema>>
|
||||
): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const id = skillIdSchema.parse(skillId)
|
||||
const catalog = await this.getSkillCatalog()
|
||||
if (!catalog.some((skill) => skill.id === id)) {
|
||||
throw new Error('Skill 不存在')
|
||||
}
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[id]: {
|
||||
...(state.skills[id] ?? defaultSkillState()),
|
||||
...update
|
||||
}
|
||||
}
|
||||
})
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
saveMcpServer(
|
||||
serverId: string | undefined,
|
||||
input: McpServerInput
|
||||
): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const value = mcpServerInputSchema.parse(input)
|
||||
if (
|
||||
value.assignments.some(
|
||||
(assignment) => assignment !== 'opencode'
|
||||
)
|
||||
) {
|
||||
throw new Error('当前版本的 MCP Server 只能分配给 OpenCode')
|
||||
}
|
||||
const state = await this.load()
|
||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||
const existing = state.mcpServers.find((server) => server.id === id)
|
||||
if (serverId && !existing) {
|
||||
throw new Error('MCP Server 不存在')
|
||||
}
|
||||
if (!existing && state.mcpServers.length >= 64) {
|
||||
throw new Error('MCP Server 数量不能超过 64 个')
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
value.secret.action === 'keep' &&
|
||||
existing.transport !== 'stdio' &&
|
||||
value.transport !== 'stdio' &&
|
||||
existing.url !== value.url &&
|
||||
existing.credential
|
||||
) {
|
||||
throw new Error('MCP 地址已更改,请重新输入或清除访问令牌')
|
||||
}
|
||||
if (value.transport === 'stdio' && value.secret.action === 'replace') {
|
||||
throw new Error('stdio MCP 不支持 Bearer Token')
|
||||
}
|
||||
|
||||
let credential =
|
||||
value.secret.action === 'keep' ? existing?.credential : undefined
|
||||
if (value.secret.action === 'replace') {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
|
||||
}
|
||||
credential = {
|
||||
formatVersion: 1 as const,
|
||||
scheme: 'electron-safe-storage' as const,
|
||||
ciphertextBase64: this.cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
serverId: id,
|
||||
secret: value.secret.value
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
}
|
||||
if (
|
||||
value.transport !== 'stdio' &&
|
||||
credential &&
|
||||
new URL(value.url).protocol !== 'https:' &&
|
||||
!['localhost', '127.0.0.1', '[::1]'].includes(
|
||||
new URL(value.url).hostname.toLowerCase()
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Bearer Token 只能通过 HTTPS 或本机回环地址发送'
|
||||
)
|
||||
}
|
||||
|
||||
const stored: StoredMcpServer =
|
||||
value.transport === 'stdio'
|
||||
? {
|
||||
id,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
enabled: value.enabled,
|
||||
assignments: value.assignments,
|
||||
transport: 'stdio',
|
||||
command: value.command,
|
||||
args: value.args
|
||||
}
|
||||
: {
|
||||
id,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
enabled: value.enabled,
|
||||
assignments: value.assignments,
|
||||
credential,
|
||||
transport: value.transport,
|
||||
url: new URL(value.url).toString()
|
||||
}
|
||||
const nextServers = existing
|
||||
? state.mcpServers.map((server) =>
|
||||
server.id === id ? stored : server
|
||||
)
|
||||
: [...state.mcpServers, stored]
|
||||
await this.persist({ ...state, mcpServers: nextServers })
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
removeMcpServer(serverId: string): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const id = mcpServerIdSchema.parse(serverId)
|
||||
const state = await this.load()
|
||||
if (!state.mcpServers.some((server) => server.id === id)) {
|
||||
throw new Error('MCP Server 不存在')
|
||||
}
|
||||
await this.persist({
|
||||
...state,
|
||||
mcpServers: state.mcpServers.filter((server) => server.id !== id)
|
||||
})
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
async getResolvedMcpServer(serverId: string): Promise<ResolvedMcpServer> {
|
||||
const id = mcpServerIdSchema.parse(serverId)
|
||||
const state = await this.load()
|
||||
const server = state.mcpServers.find((item) => item.id === id)
|
||||
if (!server) {
|
||||
throw new Error('MCP Server 不存在')
|
||||
}
|
||||
let secret: string | undefined
|
||||
if (server.credential) {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法读取 MCP 访问令牌')
|
||||
}
|
||||
try {
|
||||
const payload = secretPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(server.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
)
|
||||
if (payload.serverId === id) {
|
||||
secret = payload.secret
|
||||
}
|
||||
} catch {
|
||||
throw new Error('MCP 访问令牌无法解密,请重新配置')
|
||||
}
|
||||
}
|
||||
return {
|
||||
...this.toMcpSummary(server),
|
||||
secret
|
||||
}
|
||||
}
|
||||
|
||||
async getSkillInstructions(
|
||||
target: RuntimeTarget,
|
||||
maximumCharacters: number
|
||||
): Promise<string> {
|
||||
const snapshot = await this.getSnapshot()
|
||||
const sections: string[] = []
|
||||
let length = 0
|
||||
for (const skill of snapshot.skills) {
|
||||
if (!skill.enabled || !skill.assignments.includes(target)) {
|
||||
continue
|
||||
}
|
||||
const root =
|
||||
skill.source === 'builtin'
|
||||
? this.builtinSkillsRoot
|
||||
: this.importedSkillsRoot
|
||||
const content = await readFile(join(root, skill.id, 'SKILL.md'), 'utf8')
|
||||
const body =
|
||||
/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ??
|
||||
''
|
||||
const section = `## ${skill.name}\n${body}`
|
||||
if (length + section.length > maximumCharacters) {
|
||||
continue
|
||||
}
|
||||
sections.push(section)
|
||||
length += section.length
|
||||
}
|
||||
return sections.length > 0
|
||||
? [
|
||||
'# GoodBuddy 已启用 Skills',
|
||||
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
|
||||
...sections
|
||||
].join('\n\n')
|
||||
: ''
|
||||
}
|
||||
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
const state = await this.load()
|
||||
const assigned = state.mcpServers.filter(
|
||||
(server) => server.enabled && server.assignments.includes(target)
|
||||
)
|
||||
return Promise.all(
|
||||
assigned.map((server) => this.getResolvedMcpServer(server.id))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedMcpServer } from './capability-service'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const client = {
|
||||
connect: vi.fn(),
|
||||
listTools: vi.fn(),
|
||||
getServerVersion: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
return {
|
||||
client,
|
||||
Client: vi.fn(function Client() {
|
||||
return client
|
||||
}),
|
||||
StdioClientTransport: vi.fn(function StdioClientTransport(
|
||||
options: unknown
|
||||
) {
|
||||
return { kind: 'stdio', options }
|
||||
}),
|
||||
StreamableHTTPClientTransport: vi.fn(
|
||||
function StreamableHTTPClientTransport(
|
||||
url: URL,
|
||||
options: unknown
|
||||
) {
|
||||
return { kind: 'http', url, options }
|
||||
}
|
||||
),
|
||||
SSEClientTransport: vi.fn(function SSEClientTransport(
|
||||
url: URL,
|
||||
options: unknown
|
||||
) {
|
||||
return { kind: 'sse', url, options }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: mocks.Client
|
||||
}))
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: mocks.StdioClientTransport
|
||||
}))
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
||||
StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport
|
||||
}))
|
||||
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
||||
SSEClientTransport: mocks.SSEClientTransport
|
||||
}))
|
||||
|
||||
import { testMcpServer } from './mcp-tester'
|
||||
|
||||
const common = {
|
||||
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
|
||||
name: 'Test MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['model'] as Array<'model' | 'opencode' | 'continue'>,
|
||||
secretConfigured: false
|
||||
}
|
||||
|
||||
describe('testMcpServer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.client.connect.mockResolvedValue(undefined)
|
||||
mocks.client.listTools.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search documents'
|
||||
}
|
||||
]
|
||||
})
|
||||
mocks.client.getServerVersion.mockReturnValue({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
})
|
||||
mocks.client.close.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('uses separated stdio command arguments and closes the client', async () => {
|
||||
const result = await testMcpServer({
|
||||
...common,
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js', '--safe']
|
||||
} satisfies ResolvedMcpServer)
|
||||
|
||||
expect(mocks.StdioClientTransport).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: ['server.js', '--safe'],
|
||||
stderr: 'ignore',
|
||||
maxBufferSize: 2 * 1024 * 1024
|
||||
})
|
||||
expect(mocks.client.connect).toHaveBeenCalledOnce()
|
||||
expect(mocks.client.listTools).toHaveBeenCalledOnce()
|
||||
expect(mocks.client.close).toHaveBeenCalledOnce()
|
||||
expect(result).toEqual({
|
||||
serverName: 'test-server',
|
||||
serverVersion: '1.0.0',
|
||||
toolCount: 1,
|
||||
tools: [{ name: 'search', description: 'Search documents' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('injects a bearer token only into the remote transport', async () => {
|
||||
await testMcpServer({
|
||||
...common,
|
||||
transport: 'http',
|
||||
url: 'https://mcp.example.com/mcp',
|
||||
secretConfigured: true,
|
||||
secret: 'test-secret'
|
||||
} satisfies ResolvedMcpServer)
|
||||
|
||||
expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce()
|
||||
const [url, options] =
|
||||
mocks.StreamableHTTPClientTransport.mock.calls[0] ?? []
|
||||
expect(url).toEqual(new URL('https://mcp.example.com/mcp'))
|
||||
expect(options).toMatchObject({
|
||||
requestInit: {
|
||||
headers: { Authorization: 'Bearer test-secret' }
|
||||
},
|
||||
reconnectionOptions: { maxRetries: 0 }
|
||||
})
|
||||
expect(options).toHaveProperty('fetch')
|
||||
})
|
||||
|
||||
it('closes the client and returns a controlled error on failure', async () => {
|
||||
mocks.client.connect.mockRejectedValue(
|
||||
new Error('server included sensitive diagnostics')
|
||||
)
|
||||
|
||||
await expect(
|
||||
testMcpServer({
|
||||
...common,
|
||||
transport: 'sse',
|
||||
url: 'https://mcp.example.com/sse'
|
||||
} satisfies ResolvedMcpServer)
|
||||
).rejects.toThrow(
|
||||
'MCP Server 连接失败,请检查地址、命令和服务状态'
|
||||
)
|
||||
expect(mocks.client.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type {
|
||||
FetchLike,
|
||||
Transport
|
||||
} from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import type { McpServerTestResult } from '../../shared/capability-contracts'
|
||||
import type { ResolvedMcpServer } from './capability-service'
|
||||
|
||||
const MCP_TEST_TIMEOUT_MS = 12_000
|
||||
|
||||
function validateRemoteUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, '')
|
||||
if (
|
||||
hostname === '169.254.169.254' ||
|
||||
hostname === 'metadata.google.internal' ||
|
||||
hostname.endsWith('.internal.metadata')
|
||||
) {
|
||||
throw new Error('MCP 地址不能指向云平台元数据服务')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function createRestrictedFetch(origin: string): FetchLike {
|
||||
return async (input, init) => {
|
||||
const url = new URL(String(input))
|
||||
if (url.origin !== origin) {
|
||||
throw new Error('MCP Server 尝试访问未授权的跨域地址')
|
||||
}
|
||||
return fetch(url, {
|
||||
...init,
|
||||
redirect: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createTransport(server: ResolvedMcpServer): Transport {
|
||||
if (server.transport === 'stdio') {
|
||||
return new StdioClientTransport({
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
stderr: 'ignore',
|
||||
maxBufferSize: 2 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
const url = validateRemoteUrl(server.url)
|
||||
const requestInit: RequestInit | undefined = server.secret
|
||||
? {
|
||||
headers: {
|
||||
Authorization: `Bearer ${server.secret}`
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
const safeFetch = createRestrictedFetch(url.origin)
|
||||
|
||||
return server.transport === 'http'
|
||||
? new StreamableHTTPClientTransport(url, {
|
||||
fetch: safeFetch,
|
||||
requestInit,
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 500,
|
||||
maxReconnectionDelay: 2_000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 0
|
||||
}
|
||||
})
|
||||
: new SSEClientTransport(url, {
|
||||
fetch: safeFetch,
|
||||
requestInit
|
||||
})
|
||||
}
|
||||
|
||||
export async function testMcpServer(
|
||||
server: ResolvedMcpServer
|
||||
): Promise<McpServerTestResult> {
|
||||
const client = new Client({
|
||||
name: 'goodbuddy',
|
||||
version: '0.1.0'
|
||||
})
|
||||
const transport = createTransport(server)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort(new Error('MCP 连接测试超时'))
|
||||
}, MCP_TEST_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
await client.connect(transport, {
|
||||
timeout: MCP_TEST_TIMEOUT_MS,
|
||||
signal: controller.signal
|
||||
})
|
||||
const result = await client.listTools(undefined, {
|
||||
timeout: MCP_TEST_TIMEOUT_MS,
|
||||
signal: controller.signal
|
||||
})
|
||||
const version = client.getServerVersion()
|
||||
return {
|
||||
serverName: version?.name.slice(0, 120),
|
||||
serverVersion: version?.version.slice(0, 64),
|
||||
toolCount: result.tools.length,
|
||||
tools: result.tools.slice(0, 100).map((tool) => ({
|
||||
name: tool.name.slice(0, 128),
|
||||
description: tool.description?.slice(0, 500)
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('MCP 连接测试超时', { cause: error })
|
||||
}
|
||||
throw new Error(
|
||||
error instanceof Error &&
|
||||
/unauthorized|401|403/iu.test(error.message)
|
||||
? 'MCP Server 拒绝了访问,请检查 Bearer Token'
|
||||
: 'MCP Server 连接失败,请检查地址、命令和服务状态',
|
||||
{ cause: error }
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await client.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user