200 lines
12 KiB
TypeScript
200 lines
12 KiB
TypeScript
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<string> {
|
|
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<string> {
|
|
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<string, unknown>, 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<never>((_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<void>(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 }))
|