import { createHash, randomUUID } from 'node:crypto' import { unzipSync } from 'fflate' import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' import { basename, dirname, extname, join } from 'node:path' import { parse as parseYaml } from 'yaml' import { z } from 'zod' import { browserProfileIdSchema, browserProfileNameSchema, browserProfilesSummarySchema, builtinMcpAssignmentsSchema, builtinMcpServerIdSchema, builtinMcpServerStateSummarySchema, capabilityDiagnosticReportSchema, capabilityAssignmentsSchema, computerCapabilityConfigSummarySchema, computerCapabilityIdSchema, mcpServerIdSchema, mcpServerInputSchema, mcpServerSummarySchema, runtimeTargetSchema, skillIdSchema, skillSummarySchema, webSearchCapabilitySchema, type CapabilityAssignments, type CapabilityDiagnosticReport, type CapabilitySnapshot, type BrowserProfilesSummary, type BuiltinMcpServerId, type ComputerCapabilityId, type McpServerInput, type McpServerSummary, type RuntimeTarget, type SkillSummary } from '../../shared/capability-contracts' import type { SettingsWarning } from '../../shared/settings-warning-contracts' import { assertSupportedSettingsVersion, isolateCorruptSettingsFile, isMissingFileError, type SettingsFileOperations, UnsupportedSettingsVersionError, writeJsonFileAtomically } from '../settings-file-utils' import { decryptSettingsCredential, encryptedSettingsCredentialSchema, encryptSettingsCredential, type SettingsCredentialCipher } from '../settings-credential-cipher' import { BrowserProfileService, FileBrowserProfileStore, type BrowserProfileState } from './browser-profile-service' import { CapabilityDiagnostics, type CapabilityDiagnosticCheck } from './capability-diagnostics' import { computerCapabilityCatalog, getComputerCapability, isComputerCapabilitySupported, type ComputerCapabilityImplementationKind } from './computer-capability-catalog' 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 MAX_SKILL_DISCOVERY_DEPTH = 4 const MAX_SKILL_DISCOVERY_RESULTS = 64 const MAX_SKILL_INSTRUCTION_CHARACTERS = 262_144 const SKILL_DISCOVERY_IGNORED_DIRECTORIES = new Set([ 'node_modules', '__pycache__', '__MACOSX' ]) // Block scalars in SKILL.md frontmatter carry newlines that would break the // single-line summary surfaces the renderer and runtimes rely on. function collapsedText(maximum: number): z.ZodType { return z .string() .transform((value) => value.replace(/\s+/gu, ' ').trim()) .pipe(z.string().min(1).max(maximum)) } const skillMetadataSchema = z.object({ id: skillIdSchema.optional(), name: collapsedText(80), description: collapsedText(500), version: collapsedText(32).optional(), tags: z.array(collapsedText(32)).max(12).default([]) }) const skillStateSchema = z .object({ enabled: z.boolean(), assignments: capabilityAssignmentsSchema }) .strict() const builtinMcpServerStateSchema = z .object({ enabled: z.boolean(), assignments: builtinMcpAssignmentsSchema }) .strict() const builtinMcpServerStatesSchema = z .object({ 'knowledge-base': builtinMcpServerStateSchema, 'magic-notes': builtinMcpServerStateSchema, 'goodbuddy-config': builtinMcpServerStateSchema }) .strict() const encryptedSecretSchema = encryptedSettingsCredentialSchema.optional() const storedMcpCommonShape = { id: mcpServerIdSchema, name: z.string(), description: z.string(), enabled: z.boolean(), allowDynamicTools: z.boolean().default(false), 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 storedCapabilitiesV1Schema = z .object({ version: z.literal(1), skills: z.record(skillIdSchema, skillStateSchema), mcpServers: z.array(storedMcpServerSchema).max(64) }) .strict() const computerCapabilityStateSchema = z .object({ enabled: z.boolean(), browserProfileId: browserProfileIdSchema.nullable() }) .strict() const storedCapabilitiesV2Schema = z .object({ version: z.literal(2), skills: z.record(skillIdSchema, skillStateSchema), mcpServers: z.array(storedMcpServerSchema).max(64), computerCapabilities: z .object({ 'host-browser-control': computerCapabilityStateSchema, 'linux-desktop-control': computerCapabilityStateSchema }) .strict() }) .strict() const webSearchStateSchema = z .object({ enabled: z.boolean() }) .strict() const storedCapabilitiesV3Schema = z .object({ version: z.literal(3), skills: z.record(skillIdSchema, skillStateSchema), mcpServers: z.array(storedMcpServerSchema).max(64), webSearch: webSearchStateSchema, computerCapabilities: z .object({ 'host-browser-control': computerCapabilityStateSchema, 'linux-desktop-control': computerCapabilityStateSchema }) .strict() }) .strict() const storedCapabilitiesV4Schema = storedCapabilitiesV3Schema.extend({ version: z.literal(4) }) const storedCapabilitiesSchema = storedCapabilitiesV4Schema.extend({ version: z.literal(5), builtinMcpServers: builtinMcpServerStatesSchema }) type StoredCapabilitiesV1 = z.infer type StoredCapabilities = z.infer type StoredMcpServer = z.infer const secretPayloadSchema = z .object({ version: z.literal(1), serverId: mcpServerIdSchema, secret: z.string() }) .strict() export type CapabilityCipher = SettingsCredentialCipher export type ResolvedMcpServer = McpServerSummary & { secret?: string } export type RuntimeSkillPackage = { id: string directory: string } export type RuntimeSkillContext = { instructions: string packages: RuntimeSkillPackage[] } export type SkillImportInspection = { sourcePath: string digest: string skills: Array> } export type CapabilityServiceOptions = Readonly<{ platform?: NodeJS.Platform architecture?: string electronTarget?: boolean browserProfiles?: BrowserProfileService diagnostics?: CapabilityDiagnostics availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[] settingsFileOperations?: Partial }> function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] { return { 'host-browser-control': { enabled: false, browserProfileId: null }, 'linux-desktop-control': { enabled: false, browserProfileId: null } } } function defaultBuiltinMcpServerStates(): StoredCapabilities['builtinMcpServers'] { const defaultState = (): z.infer => ({ enabled: true, assignments: ['model', 'opencode', 'continue'] }) return { 'knowledge-base': defaultState(), 'magic-notes': defaultState(), 'goodbuddy-config': defaultState() } } function emptyStoredCapabilities( webSearchEnabled = true ): StoredCapabilities { return { version: 5, skills: {}, builtinMcpServers: defaultBuiltinMcpServerStates(), mcpServers: [], webSearch: { enabled: webSearchEnabled }, computerCapabilities: defaultComputerCapabilityStates() } } function defaultSkillState(): z.infer { return { enabled: true, assignments: [ 'model', 'opencode', 'continue', 'deepseek-harness' ] } } async function readSkill( directoryPath: string, source: SkillSummary['source'], expectedId: string | null = basename(directoryPath) ): Promise> { 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') return parseSkillContent( content, basename(directoryPath), source, expectedId ) } function parseSkillContent( content: string, displayName: string, source: SkillSummary['source'], expectedId: string | null ): Omit { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content) if (!match?.[1] || !match[2]?.trim()) { throw new Error(`${displayName} 的 SKILL.md 格式无效`) } const metadata = skillMetadataSchema.parse(parseYaml(match[1])) // Standard SKILL.md files identify the skill by `name`; GoodBuddy packages // add an explicit `id` alongside a human-readable `name`. const identifier = skillIdSchema.safeParse(metadata.id ?? metadata.name) if (!identifier.success) { throw new Error( `${displayName} 的 SKILL.md 缺少可用的 Skill ID,请提供小写连字符格式的 id 或 name` ) } if (expectedId !== null && identifier.data !== expectedId) { throw new Error(`Skill ID 必须与目录名一致:${identifier.data}`) } return skillSummarySchema .omit({ enabled: true, assignments: true }) .parse({ ...metadata, id: identifier.data, source, digest: createHash('sha256').update(content).digest('hex') }) } async function listSkills( root: string, source: SkillSummary['source'] ): Promise>> { let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { if (isMissingFileError(error)) { return [] } throw error } return Promise.all( entries .filter( (entry) => entry.isDirectory() && !entry.name.startsWith('.') ) .map((entry) => readSkill(join(root, entry.name), source)) ) } async function pathExists(candidate: string): Promise { return stat(candidate) .then(() => true) .catch(() => false) } // Mirrors the conventional layout where the first directory containing // SKILL.md is the skill root, so users can pick a suite directory that holds // many skills instead of one package at a time. async function discoverSkillDirectories(root: string): Promise { if (await pathExists(join(root, 'SKILL.md'))) { return [root] } const found: string[] = [] const walk = async (current: string, depth: number): Promise => { if (depth > MAX_SKILL_DISCOVERY_DEPTH || found.length > MAX_SKILL_DISCOVERY_RESULTS) { return } let entries try { entries = await readdir(current, { withFileTypes: true }) } catch { return } for (const entry of entries) { if ( !entry.isDirectory() || entry.name.startsWith('.') || SKILL_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name) ) { continue } const child = join(current, entry.name) if (await pathExists(join(child, 'SKILL.md'))) { found.push(child) continue } await walk(child, depth + 1) } } await walk(root, 0) if (found.length > MAX_SKILL_DISCOVERY_RESULTS) { throw new Error( `所选目录包含的 Skill 超过 ${MAX_SKILL_DISCOVERY_RESULTS} 个,请选择更精确的目录` ) } return found.sort((left, right) => left.localeCompare(right)) } type SkillPackageFile = { relativePath: string contents: Buffer } async function captureSkillPackage( sourceRoot: string ): Promise { let fileCount = 0 let totalBytes = 0 const files: SkillPackageFile[] = [] const captureDirectory = async ( source: string, relativeRoot: string, depth: number ): Promise => { if (depth > MAX_SKILL_DEPTH) { throw new Error('Skill 目录层级超过安全限制') } const entries = (await readdir(source, { withFileTypes: true })).sort( (left, right) => left.name.localeCompare(right.name) ) for (const entry of entries) { const sourcePath = join(source, entry.name) const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name const details = await lstat(sourcePath) if (details.isSymbolicLink()) { throw new Error('Skill 包不能包含符号链接') } if (details.isDirectory()) { await captureDirectory(sourcePath, relativePath, depth + 1) continue } if (!details.isFile()) { throw new Error('Skill 包只能包含普通文件和目录') } const contents = await readFile(sourcePath) if (contents.byteLength !== details.size) { throw new Error('Skill 内容在读取期间已发生变化,请重试') } fileCount += 1 totalBytes += contents.byteLength if ( fileCount > MAX_SKILL_PACKAGE_FILES || contents.byteLength > MAX_SKILL_FILE_BYTES || totalBytes > MAX_SKILL_PACKAGE_BYTES ) { throw new Error('Skill 包大小或文件数量超过安全限制') } files.push({ relativePath, contents }) } } await captureDirectory(sourceRoot, '', 0) return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath) ) } function digestSkillFiles(files: readonly SkillPackageFile[]): string { const packageHash = createHash('sha256') for (const file of files) { packageHash.update( `file\0${file.relativePath}\0${file.contents.byteLength}\0` ) packageHash.update(file.contents) packageHash.update('\0') } return packageHash.digest('hex') } async function writeSkillPackageFiles( files: readonly SkillPackageFile[], targetRoot: string ): Promise { await mkdir(targetRoot, { recursive: true }) for (const file of files) { const targetPath = join(targetRoot, ...file.relativePath.split('/')) await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, file.contents, { mode: 0o600 }) } } async function copySkillPackage( sourceRoot: string, targetRoot: string ): Promise { const files = await captureSkillPackage(sourceRoot) await writeSkillPackageFiles(files, targetRoot) return digestSkillFiles(files) } async function digestSkillPackage(sourceRoot: string): Promise { return digestSkillFiles(await captureSkillPackage(sourceRoot)) } function parseSkillZipPath(path: string): string[] { const normalized = path.replaceAll('\\', '/') const withoutTrailingSlash = normalized.replace(/\/+$/u, '') if ( !withoutTrailingSlash || normalized.startsWith('/') || /^[a-z]:/iu.test(normalized) ) { throw new Error('Skill ZIP 包含不安全路径') } const segments = withoutTrailingSlash.split('/') if ( segments.some( (segment) => !segment || segment === '.' || segment === '..' || segment.length > 255 || [...segment].some((character) => { const code = character.charCodeAt(0) return code <= 31 || code === 127 }) ) || normalized.length > 512 ) { throw new Error('Skill ZIP 包含不安全路径') } return segments } function isIgnoredSkillZipPath(segments: readonly string[]): boolean { return ( segments[0] === '__MACOSX' || segments.at(-1) === '.DS_Store' ) } type ParsedSkillZip = { directoryName?: string files: SkillPackageFile[] } async function parseSkillZip(archivePath: string): Promise { const archiveDetails = await stat(archivePath) if ( !archiveDetails.isFile() || archiveDetails.size > MAX_SKILL_PACKAGE_BYTES ) { throw new Error('Skill ZIP 文件无效或过大') } const archiveBytes = await readFile(archivePath) const selectedPaths = new Map() const normalizedPaths = new Set() let fileCount = 0 let totalBytes = 0 let files: Record try { files = unzipSync(archiveBytes, { filter: (file) => { const segments = parseSkillZipPath(file.name) if ( file.name.endsWith('/') || isIgnoredSkillZipPath(segments) ) { return false } const normalizedPath = segments.join('/').toLowerCase() if (normalizedPaths.has(normalizedPath)) { throw new Error('Skill ZIP 包含重复文件路径') } normalizedPaths.add(normalizedPath) fileCount += 1 totalBytes += file.originalSize if ( fileCount > MAX_SKILL_PACKAGE_FILES || file.originalSize > MAX_SKILL_FILE_BYTES || totalBytes > MAX_SKILL_PACKAGE_BYTES ) { throw new Error('Skill ZIP 大小或文件数量超过安全限制') } selectedPaths.set(file.name, segments) return true } }) } catch (error) { if (error instanceof Error && error.message.startsWith('Skill ZIP')) { throw error } throw new Error('Skill ZIP 文件无效或不受支持', { cause: error }) } const skillEntries = [...selectedPaths.entries()].filter( ([, segments]) => segments.at(-1) === 'SKILL.md' ) if (skillEntries.length !== 1) { throw new Error('Skill ZIP 必须且只能包含一个 SKILL.md') } const packageRoot = skillEntries[0]![1].slice(0, -1) const packageRootKey = packageRoot .map((segment) => segment.toLowerCase()) for (const segments of selectedPaths.values()) { const belongsToPackage = packageRootKey.every( (segment, index) => segments[index]?.toLowerCase() === segment ) if (!belongsToPackage || segments.length <= packageRoot.length) { throw new Error('Skill ZIP 只能包含一个 Skill 包') } if (segments.length - packageRoot.length - 1 > MAX_SKILL_DEPTH) { throw new Error('Skill ZIP 目录层级超过安全限制') } } return { ...(packageRoot.at(-1) ? { directoryName: packageRoot.at(-1) } : {}), files: Object.entries(files) .flatMap(([archiveName, contents]) => { const segments = selectedPaths.get(archiveName) if (!segments) { return [] } return [ { relativePath: segments.slice(packageRoot.length).join('/'), contents: Buffer.from(contents) } ] }) .sort((left, right) => left.relativePath.localeCompare(right.relativePath) ) } } export class CapabilityService { private state?: StoredCapabilities private loadPromise?: Promise private warnings: SettingsWarning[] = [] private updateQueue: Promise = Promise.resolve() private readonly platform: NodeJS.Platform private readonly architecture: string private readonly electronTarget: boolean private readonly browserProfiles: BrowserProfileService private readonly diagnostics: CapabilityDiagnostics private readonly settingsFileOperations?: Partial private readonly availableComputerCapabilityImplementations: ReadonlySet constructor( private readonly filePath: string, private readonly builtinSkillsRoot: string, private readonly importedSkillsRoot: string, private readonly cipher: CapabilityCipher, options: CapabilityServiceOptions = {} ) { this.platform = options.platform ?? process.platform this.architecture = options.architecture ?? process.arch this.electronTarget = options.electronTarget ?? Boolean(process.versions.electron) this.availableComputerCapabilityImplementations = new Set( options.availableComputerCapabilityImplementations ?? [ 'managed-browser-driver' ] ) this.settingsFileOperations = options.settingsFileOperations this.browserProfiles = options.browserProfiles ?? new BrowserProfileService( new FileBrowserProfileStore( join(dirname(this.filePath), 'browser-profiles') ) ) const checks: CapabilityDiagnosticCheck[] = [ { id: 'browser-executable', run: async () => this.electronTarget ? { status: 'available', summary: 'GoodBuddy 的受管 Electron 浏览器核心可用。' } : { status: 'unavailable', summary: '当前进程不是受支持的 Electron 桌面目标。', remedy: '请从 GoodBuddy 桌面应用运行此诊断。' } }, { id: 'managed-profile-root', run: async () => { await this.browserProfiles.getSnapshot() return { status: 'available', summary: '隔离的托管浏览器配置存储可用。' } } } ] this.diagnostics = options.diagnostics ?? new CapabilityDiagnostics(checks) } private load(): Promise { if (this.state) { return Promise.resolve(this.state) } if (this.loadPromise) { return this.loadPromise } const pending = this.loadUncached() const tracked = pending.catch((error: unknown) => { if (this.loadPromise === tracked) { this.loadPromise = undefined this.state = undefined } throw error }) this.loadPromise = tracked return tracked } private async loadUncached(): Promise { let loaded: StoredCapabilities let shouldPersist = false try { const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown assertSupportedSettingsVersion(raw, 5, (version) => `当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试` ) const version = z .object({ version: z.union([ z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5) ]) }) .passthrough() .parse(raw).version if (version === 1) { const legacy: StoredCapabilitiesV1 = storedCapabilitiesV1Schema.parse(raw) loaded = { version: 5, skills: legacy.skills, builtinMcpServers: defaultBuiltinMcpServerStates(), mcpServers: legacy.mcpServers, webSearch: { enabled: true }, computerCapabilities: defaultComputerCapabilityStates() } shouldPersist = true } else if (version === 2) { const legacy = storedCapabilitiesV2Schema.parse(raw) loaded = { ...legacy, version: 5, builtinMcpServers: defaultBuiltinMcpServerStates(), webSearch: { enabled: true } } shouldPersist = true } else if (version === 3) { const legacy = storedCapabilitiesV3Schema.parse(raw) loaded = { ...legacy, version: 5, builtinMcpServers: defaultBuiltinMcpServerStates() } shouldPersist = true } else if (version === 4) { const legacy = storedCapabilitiesV4Schema.parse(raw) loaded = { ...legacy, version: 5, builtinMcpServers: defaultBuiltinMcpServerStates() } shouldPersist = true } else { loaded = storedCapabilitiesSchema.parse(raw) } } catch (error) { if (error instanceof UnsupportedSettingsVersionError) { throw error } if (isMissingFileError(error)) { loaded = emptyStoredCapabilities() } else { await isolateCorruptSettingsFile( this.filePath, '能力设置已损坏且无法隔离', Date.now, this.settingsFileOperations ) this.warnings = [{ code: 'capability-settings-recovered' }] loaded = emptyStoredCapabilities(false) shouldPersist = true } } this.state = storedCapabilitiesSchema.parse(loaded) await this.validateBrowserProfileReferences(this.state) if (shouldPersist) { await this.persist(this.state) } return this.state } private async validateBrowserProfileReferences( state: StoredCapabilities ): Promise { const profiles = await this.browserProfiles.getSnapshot() const profileIds = new Set(profiles.profiles.map((profile) => profile.id)) for (const capability of computerCapabilityCatalog) { const profileId = state.computerCapabilities[capability.id].browserProfileId if (profileId && !profileIds.has(profileId)) { throw new Error('电脑控制能力引用了不存在的浏览器配置') } if (capability.id !== 'host-browser-control' && profileId) { throw new Error('此电脑控制能力不支持浏览器配置') } } } private queue(operation: () => Promise): Promise { const result = this.updateQueue.then(operation) this.updateQueue = result.then( () => undefined, () => undefined ) return result } private async persist(state: StoredCapabilities): Promise { const validated = storedCapabilitiesSchema.parse(state) await writeJsonFileAtomically( this.filePath, validated, this.settingsFileOperations ) this.state = validated } private clearRecoveryWarnings(): void { this.warnings = this.warnings.filter( (warning) => warning.code !== 'capability-settings-recovered' ) } private async persistUserChange( state: StoredCapabilities ): Promise { await this.persist(state) this.clearRecoveryWarnings() } private async getSkillCatalog(): Promise< Array> > { 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 { const [state, catalog, browserProfileState] = await Promise.all([ this.load(), this.getSkillCatalog(), this.browserProfiles.getSnapshot() ]) 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 ), builtinMcpServers: builtinMcpServerIdSchema.options.map((id) => builtinMcpServerStateSummarySchema.parse({ id, ...state.builtinMcpServers[id] }) ), mcpServers: state.mcpServers.map((server) => this.toMcpSummary(server) ), webSearch: webSearchCapabilitySchema.parse({ provider: 'exa', enabled: state.webSearch.enabled, availableIn: ['ask', 'execute'], tools: ['web_search', 'web_fetch'] }), computerCapabilities: computerCapabilityCatalog.map((capability) => computerCapabilityConfigSummarySchema.parse({ id: capability.id, name: capability.name, description: capability.description, enabled: state.computerCapabilities[capability.id].enabled, supported: isComputerCapabilitySupported( capability, this.platform, this.architecture, this.availableComputerCapabilityImplementations ), browserProfileId: state.computerCapabilities[capability.id].browserProfileId, riskSummary: capability.riskSummary }) ), browserProfiles: this.toBrowserProfilesSummary(browserProfileState), ...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {}) } } async getConfigurationDigest(): Promise { const state = await this.load() const sanitized = { ...state, mcpServers: state.mcpServers.map((server) => ({ ...server, credentialConfigured: Boolean(server.credential), credential: undefined })) } return createHash('sha256') .update(JSON.stringify(sanitized)) .digest('hex') } async getWebSearchCapabilityStatus(): Promise<{ enabled: boolean }> { const state = await this.load() return { enabled: state.webSearch.enabled } } setWebSearchEnabled(enabled: boolean): Promise { return this.queue(async () => { const state = await this.load() await this.persistUserChange({ ...state, webSearch: { enabled } }) return this.getSnapshot() }) } async getComputerCapabilityStatus( capabilityId: ComputerCapabilityId ): Promise<{ enabled: boolean; supported: boolean }> { const id = computerCapabilityIdSchema.parse(capabilityId) const capability = getComputerCapability(id) const state = await this.load() return { enabled: state.computerCapabilities[id].enabled, supported: isComputerCapabilitySupported( capability, this.platform, this.architecture, this.availableComputerCapabilityImplementations ) } } private toBrowserProfilesSummary( state: BrowserProfileState ): BrowserProfilesSummary { return browserProfilesSummarySchema.parse({ profiles: state.profiles.map((profile) => ({ id: profile.id, name: profile.name, mode: profile.mode })), defaultProfileId: state.defaultProfileId }) } setComputerCapabilityEnabled( capabilityId: ComputerCapabilityId, enabled: boolean ): Promise { return this.queue(async () => { const id = computerCapabilityIdSchema.parse(capabilityId) const capability = getComputerCapability(id) if ( enabled && !isComputerCapabilitySupported( capability, this.platform, this.architecture, this.availableComputerCapabilityImplementations ) ) { throw new Error('当前操作系统或处理器架构不支持此能力') } if (enabled) { const report = await this.diagnoseComputerCapabilityState(id, true) if (report.status === 'unavailable') { throw new Error('能力诊断不可用,未启用此能力') } } const state = await this.load() await this.persistUserChange({ ...state, computerCapabilities: { ...state.computerCapabilities, [id]: { ...state.computerCapabilities[id], enabled } } }) return this.getSnapshot() }) } setComputerCapabilityBrowserProfile( capabilityId: ComputerCapabilityId, browserProfileId: string | null ): Promise { return this.queue(async () => { const id = computerCapabilityIdSchema.parse(capabilityId) const profileId = browserProfileIdSchema.nullable().parse( browserProfileId ) if (id !== 'host-browser-control' && profileId) { throw new Error('此电脑控制能力不支持浏览器配置') } const profiles = await this.browserProfiles.getSnapshot() if ( profileId && !profiles.profiles.some((profile) => profile.id === profileId) ) { throw new Error('浏览器配置不存在') } const state = await this.load() const previousProfileId = state.computerCapabilities[id].browserProfileId if (profileId === previousProfileId) { return this.getSnapshot() } const reference = { kind: 'capability' as const, id } if (profileId) { try { await this.browserProfiles.addReference(profileId, reference) } catch (error) { try { await this.browserProfiles.removeReference(profileId, reference) } catch (compensationError) { throw new AggregateError( [error, compensationError], '浏览器配置引用添加失败,且补偿清理未完成', { cause: compensationError } ) } throw error } } const nextState: StoredCapabilities = { ...state, computerCapabilities: { ...state.computerCapabilities, [id]: { ...state.computerCapabilities[id], browserProfileId: profileId } } } try { await this.persistUserChange(nextState) } catch (error) { if (profileId) { try { await this.browserProfiles.removeReference(profileId, reference) } catch (compensationError) { throw new AggregateError( [error, compensationError], '电脑控制配置保存失败,且新增引用补偿清理未完成', { cause: compensationError } ) } } throw error } if (previousProfileId) { try { await this.browserProfiles.removeReference( previousProfileId, reference ) } catch (error) { try { await this.browserProfiles.addReference( previousProfileId, reference ) await this.persistUserChange(state) if (profileId) { await this.browserProfiles.removeReference( profileId, reference ) } } catch (compensationError) { throw new AggregateError( [error, compensationError], '旧浏览器配置引用移除失败,且回滚未完成', { cause: compensationError } ) } throw error } } return this.getSnapshot() }) } async diagnoseComputerCapability( capabilityId: ComputerCapabilityId ): Promise { const id = computerCapabilityIdSchema.parse(capabilityId) const state = await this.load() return this.diagnoseComputerCapabilityState( id, state.computerCapabilities[id].enabled ) } private async diagnoseComputerCapabilityState( id: ComputerCapabilityId, enabled: boolean ): Promise { if ( id === 'host-browser-control' && enabled && !this.electronTarget ) { return capabilityDiagnosticReportSchema.parse({ capabilityId: id, status: 'unavailable', checkedAt: new Date().toISOString(), checks: [ { id: 'electron-target', status: 'unavailable', summary: '当前进程不是受支持的 Electron 桌面目标。', remedy: '请从 GoodBuddy 桌面应用运行此诊断。' } ] }) } return capabilityDiagnosticReportSchema.parse( await this.diagnostics.diagnose({ capabilityId: id, enabled, platform: this.platform, architecture: this.architecture, availableImplementationKinds: this.availableComputerCapabilityImplementations }) ) } createBrowserProfile(name: string): Promise { return this.queue(async () => { await this.browserProfiles.createProfile( browserProfileNameSchema.parse(name) ) this.clearRecoveryWarnings() return this.getSnapshot() }) } renameBrowserProfile( profileId: string, name: string ): Promise { return this.queue(async () => { await this.browserProfiles.renameProfile( browserProfileIdSchema.parse(profileId), browserProfileNameSchema.parse(name) ) this.clearRecoveryWarnings() return this.getSnapshot() }) } setDefaultBrowserProfile(profileId: string): Promise { return this.queue(async () => { await this.browserProfiles.setDefaultProfile( browserProfileIdSchema.parse(profileId) ) this.clearRecoveryWarnings() return this.getSnapshot() }) } removeBrowserProfile(profileId: string): Promise { return this.queue(async () => { await this.browserProfiles.deleteProfile( browserProfileIdSchema.parse(profileId) ) this.clearRecoveryWarnings() return this.getSnapshot() }) } private async stageSkillDirectory( sourceDirectory: string, expectedId: string | null | undefined, expectedPackageDigest?: string ): Promise<{ skill: Omit temporaryPath: string targetPath: string }> { const temporaryPath = join( this.importedSkillsRoot, `.import-${randomUUID()}` ) try { const skill = await readSkill( sourceDirectory, 'imported', expectedId ) 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 pathExists(targetPath)) { throw new Error('同名 Skill 已导入,请先删除后重试') } const copiedDigest = await copySkillPackage( sourceDirectory, temporaryPath ) if ( expectedPackageDigest !== undefined && copiedDigest !== expectedPackageDigest ) { throw new Error( 'Skill 内容在确认后已发生变化,请重新生成导入计划' ) } await readSkill(temporaryPath, 'imported', skill.id) return { skill, temporaryPath, targetPath } } catch (error) { await rm(temporaryPath, { recursive: true, force: true }) throw error } } private async importSkillDirectory( sourceDirectory: string, expectedId: string | null | undefined, initialState: z.infer = defaultSkillState(), expectedPackageDigest?: string ): Promise { const staged = await this.stageSkillDirectory( sourceDirectory, expectedId, expectedPackageDigest ) try { await rename(staged.temporaryPath, staged.targetPath) const state = await this.load() await this.persistUserChange({ ...state, skills: { ...state.skills, [staged.skill.id]: skillStateSchema.parse(initialState) } }) return staged.skill.id } catch (error) { await rm(staged.temporaryPath, { recursive: true, force: true }) throw error } } async inspectSkillImport( sourcePath: string ): Promise { const canonicalSource = await realpath(sourcePath) const sourceDetails = await stat(canonicalSource) const isDirectory = sourceDetails.isDirectory() const isZip = sourceDetails.isFile() && extname(canonicalSource).toLowerCase() === '.zip' if (!isDirectory && !isZip) { throw new Error('所选 Skill 路径必须是目录或 .zip 文件') } let directories: string[] let expectedId: string | null | undefined let zipFiles: SkillPackageFile[] | undefined if (isZip) { const parsedZip = await parseSkillZip(canonicalSource) zipFiles = parsedZip.files directories = [] expectedId = parsedZip.directoryName ?? null } else { directories = await discoverSkillDirectories(canonicalSource) if (directories.length === 0) { throw new Error( '所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录' ) } } const skills = isZip ? [ parseSkillContent( zipFiles ?.find((file) => file.relativePath === 'SKILL.md') ?.contents.toString('utf8') ?? '', expectedId ?? 'Skill ZIP', 'imported', expectedId ?? null ) ] : await Promise.all( directories.map((directory) => readSkill( directory, 'imported', directories.length === 1 ? undefined : null ) ) ) const ids = new Set(skills.map((skill) => skill.id)) if (ids.size !== skills.length) { throw new Error('所选目录包含重复的 Skill ID') } const [builtins, imported] = await Promise.all([ listSkills(this.builtinSkillsRoot, 'builtin'), listSkills(this.importedSkillsRoot, 'imported') ]) const unavailableIds = new Set([ ...builtins.map((skill) => skill.id), ...imported.map((skill) => skill.id) ]) const conflict = skills.find((skill) => unavailableIds.has(skill.id)) if (conflict) { throw new Error( builtins.some((skill) => skill.id === conflict.id) ? `导入的 Skill ID 与内置 Skill 冲突:${conflict.id}` : `同名 Skill 已导入:${conflict.id}` ) } const packageDigests = isZip ? [digestSkillFiles(zipFiles ?? [])] : await Promise.all( directories.map((directory) => digestSkillPackage(directory)) ) const digest = createHash('sha256') .update( skills .map((skill, index) => ({ id: skill.id, digest: packageDigests[index] })) .sort((left, right) => left.id.localeCompare(right.id)) .map((item) => `${item.id}\0${item.digest}`) .join('\0') ) .digest('hex') return { sourcePath: canonicalSource, digest, skills } } importSkill( sourcePath: string, expectedDigest?: string, initialState?: { enabled: boolean assignments: CapabilityAssignments } ): Promise { return this.queue(async () => { let inspectedPackageDigests: string[] | undefined if (expectedDigest !== undefined) { const inspection = await this.inspectSkillImport(sourcePath) if (inspection.digest !== expectedDigest) { throw new Error( 'Skill 内容在确认后已发生变化,请重新生成导入计划' ) } const inspectedSource = await realpath(sourcePath) const inspectedDetails = await stat(inspectedSource) if (inspectedDetails.isDirectory()) { inspectedPackageDigests = await Promise.all( (await discoverSkillDirectories(inspectedSource)).map( (directory) => digestSkillPackage(directory) ) ) } } const canonicalSource = await realpath(sourcePath) const sourceDetails = await stat(canonicalSource) const isDirectory = sourceDetails.isDirectory() const isZip = sourceDetails.isFile() && extname(canonicalSource).toLowerCase() === '.zip' if (!isDirectory && !isZip) { throw new Error('所选 Skill 路径必须是目录或 .zip 文件') } await mkdir(this.importedSkillsRoot, { recursive: true }) if (isZip) { const extractPath = join( this.importedSkillsRoot, `.extract-${randomUUID()}` ) try { const parsedZip = await parseSkillZip(canonicalSource) if (expectedDigest !== undefined) { const skillContent = parsedZip.files .find((file) => file.relativePath === 'SKILL.md') ?.contents.toString('utf8') ?? '' const skill = parseSkillContent( skillContent, parsedZip.directoryName ?? 'Skill ZIP', 'imported', parsedZip.directoryName ?? null ) const currentDigest = createHash('sha256') .update( `${skill.id}\0${digestSkillFiles(parsedZip.files)}` ) .digest('hex') if (currentDigest !== expectedDigest) { throw new Error( 'Skill 内容在确认后已发生变化,请重新生成导入计划' ) } } await writeSkillPackageFiles(parsedZip.files, extractPath) await this.importSkillDirectory( extractPath, parsedZip.directoryName ?? null, initialState, digestSkillFiles(parsedZip.files) ) } finally { await rm(extractPath, { recursive: true, force: true }) } return this.getSnapshot() } const directories = await discoverSkillDirectories(canonicalSource) if (directories.length === 0) { throw new Error( '所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录' ) } if ( expectedDigest !== undefined && inspectedPackageDigests?.length !== directories.length ) { throw new Error( 'Skill 内容在确认后已发生变化,请重新生成导入计划' ) } const stagedSkills: Array<{ skill: Omit temporaryPath: string targetPath: string }> = [] for (const [index, directory] of directories.entries()) { try { // A suite directory may nest skills below its own name, so the // directory name is only authoritative for a single-skill import. const staged = await this.stageSkillDirectory( directory, directories.length === 1 ? undefined : null, expectedDigest !== undefined ? inspectedPackageDigests?.[index] : undefined ) stagedSkills.push(staged) } catch (error) { await Promise.allSettled( stagedSkills.map((staged) => rm(staged.temporaryPath, { recursive: true, force: true }) ) ) throw error } } const state = await this.load() const nextSkills = { ...state.skills } for (const staged of stagedSkills) { nextSkills[staged.skill.id] = skillStateSchema.parse( initialState ?? defaultSkillState() ) } const installedPaths: string[] = [] try { for (const staged of stagedSkills) { await rename(staged.temporaryPath, staged.targetPath) installedPaths.push(staged.targetPath) } await this.persistUserChange({ ...state, skills: nextSkills }) } catch (error) { await Promise.allSettled( [ ...stagedSkills.map((staged) => staged.temporaryPath), ...installedPaths ].map((path) => rm(path, { recursive: true, force: true })) ) throw error } return this.getSnapshot() }) } removeSkill(skillId: string): Promise { 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.persistUserChange({ ...state, skills }) return this.getSnapshot() }) } setSkillEnabled( skillId: string, enabled: boolean ): Promise { return this.updateSkillState(skillId, { enabled }) } setSkillAssignments( skillId: string, assignments: CapabilityAssignments ): Promise { return this.updateSkillState(skillId, { assignments: capabilityAssignmentsSchema.parse(assignments) }) } setBuiltinMcpServerEnabled( serverId: BuiltinMcpServerId, enabled: boolean ): Promise { return this.updateBuiltinMcpServerState(serverId, { enabled }) } setBuiltinMcpServerAssignments( serverId: BuiltinMcpServerId, assignments: CapabilityAssignments ): Promise { return this.updateBuiltinMcpServerState(serverId, { assignments: builtinMcpAssignmentsSchema.parse(assignments) }) } private updateBuiltinMcpServerState( serverId: BuiltinMcpServerId, update: Partial> ): Promise { return this.queue(async () => { const id = builtinMcpServerIdSchema.parse(serverId) const state = await this.load() await this.persistUserChange({ ...state, builtinMcpServers: { ...state.builtinMcpServers, [id]: { ...state.builtinMcpServers[id], ...update } } }) return this.getSnapshot() }) } private updateSkillState( skillId: string, update: Partial> ): Promise { 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.persistUserChange({ ...state, skills: { ...state.skills, [id]: { ...(state.skills[id] ?? defaultSkillState()), ...update } } }) return this.getSnapshot() }) } saveMcpServer( serverId: string | undefined, input: McpServerInput ): Promise { return this.queue(async () => { const value = mcpServerInputSchema.parse(input) 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 = encryptSettingsCredential(this.cipher, { version: 1, serverId: id, secret: value.secret.value }) } const stored: StoredMcpServer = value.transport === 'stdio' ? { id, name: value.name, description: value.description, enabled: value.enabled, allowDynamicTools: value.allowDynamicTools, assignments: value.assignments, transport: 'stdio', command: value.command, args: value.args } : { id, name: value.name, description: value.description, enabled: value.enabled, allowDynamicTools: value.allowDynamicTools, 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.persistUserChange({ ...state, mcpServers: nextServers }) return this.getSnapshot() }) } removeMcpServer(serverId: string): Promise { 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.persistUserChange({ ...state, mcpServers: state.mcpServers.filter((server) => server.id !== id) }) return this.getSnapshot() }) } async getResolvedMcpServer(serverId: string): Promise { 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( decryptSettingsCredential(this.cipher, server.credential) ) if (payload.serverId === id) { secret = payload.secret } } catch { throw new Error('MCP 访问令牌无法解密,请重新配置') } } return { ...this.toMcpSummary(server), secret } } async getRuntimeSkillContext( target: RuntimeTarget, maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS ): Promise { const budget = Math.min( maximumCharacters, MAX_SKILL_INSTRUCTION_CHARACTERS ) const snapshot = await this.getSnapshot() const sections: string[] = [] const skipped: string[] = [] const incompatible: string[] = [] const packages: RuntimeSkillPackage[] = [] 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 directory = join(root, skill.id) const content = await readFile(join(directory, 'SKILL.md'), 'utf8') if (target === 'opencode' && skill.id.length > 64) { incompatible.push(skill.name) continue } const body = /^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ?? '' // Skill bodies reference their own scripts and templates by relative // path, which only resolve against the installed skill directory. const section = [ `## ${skill.name}`, `Skill 目录:${directory}`, body ].join('\n') if (length + section.length > budget) { skipped.push(skill.name) continue } packages.push({ id: skill.id, directory }) sections.push(section) length += section.length } if ( sections.length === 0 && skipped.length === 0 && incompatible.length === 0 ) { return { instructions: '', packages } } return { instructions: [ '# GoodBuddy 已启用 Skills', '以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。', ...(skipped.length > 0 ? [ `注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}。` ] : []), ...(incompatible.length > 0 ? [ `注意:以下 Skill 名称超过 OpenCode 的 64 字符上限,本次对话不可用:${incompatible.join('、')}。` ] : []), ...sections ].join('\n\n'), packages } } async getSkillInstructions( target: RuntimeTarget, maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS ): Promise { return ( await this.getRuntimeSkillContext(target, maximumCharacters) ).instructions } async getResolvedMcpServers( target: RuntimeTarget ): Promise { 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)) ) } async getEnabledBuiltinMcpServerIds( target: RuntimeTarget ): Promise { const runtime = runtimeTargetSchema.parse(target) if (runtime === 'deepseek-harness') { return [] } const state = await this.load() return builtinMcpServerIdSchema.options.filter((id) => { const server = state.builtinMcpServers[id] return server.enabled && server.assignments.includes(runtime) }) } }