import assert from 'node:assert/strict' import fs from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' const testRoot = await fs.mkdtemp(path.resolve('.tmp/claude-capabilities-')) const userData = path.join(testRoot, 'data') const workspace = path.join(testRoot, 'workspace') await Promise.all([userData, workspace].map(folder => fs.mkdir(folder, { recursive: true }))) const checks: string[] = [] const check = (name: string, actual: unknown, expected: unknown): void => { assert.deepEqual(actual, expected, name) checks.push(name) } function document(name: string, extra = ''): string { return `---\nname: ${name}\ndescription: Controlled ${name}\n${extra}---\nInstructions for ${name}. Read references/guide.txt when needed.\n` } async function skill(scope: '.agents/skills' | '.claude/skills', folder: string, name: string, extra = ''): Promise { const root = path.join(workspace, scope, folder) await fs.mkdir(path.join(root, 'references'), { recursive: true }) await fs.writeFile(path.join(root, 'SKILL.md'), document(name, extra)) await fs.writeFile(path.join(root, 'references', 'guide.txt'), `RESOURCE_${name}`) return root } await skill('.agents/skills', 'automatic', 'automatic') await skill('.agents/skills', 'manual', 'manual', 'disable-model-invocation: true\n') await skill('.agents/skills', 'selected-only', 'selected-only', 'user-invocable: false\n') await skill('.agents/skills', 'disabled', 'disabled') await skill('.claude/skills', 'legacy', 'legacy') const electronStub = { name: 'isolated-electron-path', setup(context: import('esbuild').PluginBuild) { context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' })) context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({ loader: 'js', contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};` })) } } async function compile(entry: string, file: string): Promise { const outfile = path.join(testRoot, file) await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning', plugins: [electronStub] }) return outfile } const adapterPath = await compile('src/main/capabilities/claude-capabilities.ts', 'adapter.mjs') const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs') const mcpPath = await compile('src/main/capabilities/mcp.ts', 'mcp.mjs') const adapter = await import(pathToFileURL(adapterPath).href) const skills = await import(pathToFileURL(skillsPath).href) const listed = await skills.listSkills(workspace) const byName = (name: string) => listed.find((item: { name: string }) => item.name === name) const automatic = byName('automatic') const manual = byName('manual') const selectedOnly = byName('selected-only') const disabled = byName('disabled') assert.ok(automatic && manual && selectedOnly && disabled) check('only .agents/skills is discovered', listed.some((item: { name: string }) => item.name === 'legacy'), false) await skills.setSkillEnabled(disabled.id, false, workspace) let executeSignal: AbortSignal | undefined let discoverOptions: { serverId?: string; query?: string } | undefined const mcp = { async discover(options?: { serverId?: string; query?: string }) { discoverOptions = options return [ { id: 'ready', name: 'Ready server', status: 'connected', available: true, tools: [ { name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }, { name: 'search-only', description: 'Find needles', inputSchema: { type: 'object', properties: {} } } ] }, { id: 'untrusted', name: 'Unapproved server', status: 'disconnected', available: false, reason: '请先批准当前 MCP 配置', tools: [] } ] }, async execute(serverId: string, toolName: string, args: Record, signal: AbortSignal) { executeSignal = signal if (serverId === 'untrusted') return { ok: false, content: '请先批准当前 MCP 配置' } if (serverId === 'disabled') return { ok: false, content: 'MCP 服务已禁用' } if (toolName === 'wait') return new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })), { once: true }) }) return { ok: true, content: `${serverId}:${String(args.text ?? '')}` } } } const runtime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [manual.id], selectedMcpServerIds: ['ready'] }, { mcp }) const rootController = new AbortController() const rootBoundRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, requestSignal: () => rootController.signal }, { mcp }) const rootBoundCall = rootBoundRuntime.callMcp('ready', 'wait', {}, new AbortController().signal) await new Promise(resolve => setTimeout(resolve, 0)) rootController.abort() await assert.rejects(rootBoundCall, /取消/) check('root cancellation reaches MCP independently of SDK callback signal', executeSignal?.aborted, true) const catalog = await runtime.list() check('automatic skill appears without selection', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === automatic.id && !item.selected), true) check('explicit manual-only skill appears as selected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === manual.id && item.selected), true) check('user-invocable=false remains model-discoverable but unselected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === selectedOnly.id && !item.selected), true) check('disabled skill is omitted', catalog.skills.some((item: { id: string }) => item.id === disabled.id), false) check('MCP selection is message-scoped metadata', [catalog.mcp.find((item: { id: string }) => item.id === 'ready').selected, catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').selected], [true, false]) check('approval failure is reported truthfully', catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').reason, '请先批准当前 MCP 配置') check('default discovery omits tool schemas', 'inputSchema' in catalog.mcp.find((item: { id: string }) => item.id === 'ready').tools[0], false) check('default discovery sends summary-only options', discoverOptions, {}) const toolSearch = await runtime.list('Echo') check('tool-only search keeps its parent server', toolSearch.mcp.map((item: { id: string }) => item.id), ['ready']) check('tool search returns only the matched tool with schema', toolSearch.mcp[0].tools, [{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }]) check('tool search forwards query to MCP discovery', discoverOptions, { query: 'Echo' }) const drilled = await runtime.list(undefined, 'ready') check('server drill-down returns all tool schemas', drilled.mcp[0].tools.every((item: { inputSchema?: unknown }) => item.inputSchema !== undefined), true) check('server drill-down forwards server identity', discoverOptions, { serverId: 'ready' }) check('automatic skill body loads on demand', (await runtime.loadSkill(automatic.id)).includes('Instructions for automatic.'), true) check('explicit selected skill body loads', (await runtime.explicitSkillContext()).includes('Instructions for manual.'), true) check('relative skill resource resolves from package root', await runtime.skillResource(automatic.id, 'references/guide.txt'), 'RESOURCE_automatic') await assert.rejects(runtime.skillResource(automatic.id, '../outside.txt'), /路径/) checks.push('resource traversal is rejected') check('automatic-only skill remains available to the model', (await runtime.loadSkill(selectedOnly.id)).includes('Instructions for selected-only.'), true) check('automatic-only resource remains model-readable', await runtime.skillResource(selectedOnly.id, 'references/guide.txt'), 'RESOURCE_selected-only') const automaticRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace }, { mcp }) await assert.rejects(automaticRuntime.loadSkill(manual.id), /自动调用/) checks.push('manual-only skill remains unavailable without an explicit reference') await assert.rejects(automaticRuntime.loadSkill(disabled.id), /禁用/) checks.push('disabled skill body cannot load') const explicitOnly = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [selectedOnly.id] }, { mcp }) await assert.rejects(explicitOnly.loadSkill(selectedOnly.id), /手动调用/) checks.push('explicit selection cannot bypass user-invocable=false') runtime.setRequestSelection({ explicitSkillIds: [], selectedMcpServerIds: [] }) const nextCatalog = await runtime.list() check('next request clears prior explicit skill and MCP selection', [nextCatalog.skills.some((item: { id: string }) => item.id === manual.id), nextCatalog.mcp.some((item: { id: string; selected: boolean }) => item.id === 'ready' && item.selected)], [false, false]) await assert.rejects(runtime.loadSkill(manual.id), /自动调用/) checks.push('explicit skill permission does not leak into the next request') check('real MCP call is passed through', await runtime.callMcp('ready', 'echo', { text: 'actual' }, new AbortController().signal), { ok: true, content: 'ready:actual' }) check('unapproved MCP call fails instead of pretending success', await runtime.callMcp('untrusted', 'echo', {}, new AbortController().signal), { ok: false, content: '请先批准当前 MCP 配置' }) check('disabled MCP call fails', await runtime.callMcp('disabled', 'echo', {}, new AbortController().signal), { ok: false, content: 'MCP 服务已禁用' }) const controller = new AbortController() const pending = runtime.callMcp('ready', 'wait', {}, controller.signal) await new Promise(resolve => setImmediate(resolve)) controller.abort() await assert.rejects(pending, (error: unknown) => (error as Error).name === 'AbortError') check('cancellation signal reaches MCP execution', executeSignal?.aborted, true) await skills.setSkillEnabled(disabled.id, false, workspace) await assert.rejects(runtime.loadSkill(disabled.id), /禁用/) checks.push('global disable takes effect on the next bridge call') await assert.rejects(runtime.skillResource(disabled.id, 'references/guide.txt'), /禁用/) checks.push('disabled skill resource cannot load') const realMcp = await import(pathToFileURL(mcpPath).href) const mcpEvents = path.join(testRoot, 'mcp-events.jsonl') try { let fixture = await realMcp.saveMcpServer({ name: 'Schema fixture', transport: 'stdio', command: process.execPath, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', mcpEvents], enabled: true }) await realMcp.trustMcpServer(fixture.id, fixture.configHash) fixture = await realMcp.connectMcpServer(fixture.id) const summary = await realMcp.discoverMcpCapabilities() check('real MCP default discovery omits schemas', summary.find((item: { id: string }) => item.id === fixture.id).tools.every((item: object) => !('inputSchema' in item)), true) const drilled = await realMcp.discoverMcpCapabilities({ serverId: fixture.id }) const echo = drilled[0].tools.find((item: { name: string }) => item.name === 'echo') check('real MCP server drill-down includes schemas', echo.inputSchema, { type: 'object', properties: { text: { type: 'string' } } }) const searched = await realMcp.discoverMcpCapabilities({ query: 'echo' }) check('real MCP tool search includes schema', searched.find((item: { id: string }) => item.id === fixture.id).tools, [{ name: 'echo', description: 'Controlled stdio echo', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }]) } finally { await realMcp.disposeMcpServers() } assert.ok(runtime.server, 'SDK MCP server should be constructed') checks.push('Claude SDK capability server constructed') await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2)) console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot }))