diff --git a/BUILD.md b/BUILD.md index b226a639..d733cea4 100644 --- a/BUILD.md +++ b/BUILD.md @@ -103,6 +103,13 @@ npm run build 中间构建输出位于 `out`。该目录为生成内容,应修改源文件后重新构建,不要直接编辑。 +Obsidian 接入的构建钩子将锁定的 npm `@bitbonsai/mcpvault@0.16.0` 及递归运行依赖、 +许可证和原生辅助资源复制到 `out/main/obsidian-mcpvault`,桌面打包时解包到 +`app.asar.unpacked`。运行时复用现有工具执行环境的 Node,不执行在线 npm 安装。 +生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的 +Windows x64 解包应用已验证;物理断网、安装器及其他系统验收待完成。 +验证记录见 [Obsidian 进度](./docs/features/obsidian/progress.md)。 + ## 图标生成 应用图标源文件位于 `icons`。修改源图或图标处理逻辑后运行: diff --git a/FEATURES.md b/FEATURES.md index 44a00e52..7bb09bdd 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -581,6 +581,17 @@ records are listed separately and do not introduce another feature status. DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority for the current request, and user configuration cannot loosen Ask/Execute read/write boundaries. +- **Obsidian integration (implemented in source, acceptance in progress)**: + Disabled by default, with all locally registered vaults as the default scope + and an optional folder that can be tested before saving or enabling. + Bundled MCPVault 0.16.0 provides all upstream abilities through 19 shared tools. + Automated production-component checks, Electron managed-Node tests, Windows + settings and chat UI checks, and restricted-network Windows x64 unpacked-app + checks passed. Physical-offline, installer, and other-platform acceptance + remain pending. DeepSeek + Harness and remote execution remain unavailable. See the + [feature docs and validation record](./docs/features/obsidian/README.md); + this is not a release claim. - [x] **MCP Tools**: Explicitly enabled custom MCP can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent Execute, and DeepSeek Harness, and loads only in Execute. Agent child processes receive only diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 0130f316..4adedad5 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -84,6 +84,7 @@ - [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。 - **本机工具执行环境验证记录**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。现有记录未覆盖真实 Skill/MCP、自定义解释器和运行中进程协调。 - [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。 +- **Obsidian 接入(源码已实现,验收中)**:默认关闭,默认发现全部本机注册仓库,也可指定文件夹并在保存或启用前测试。随包提供 MCPVault 0.16.0,19 个共享工具保留全部上游能力;生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的 Windows x64 解包应用已验证。物理断网、安装器及其他系统验收待完成。DeepSeek Harness 与远程执行保持不可用,详见[功能文档与验证记录](./docs/features/obsidian/README.md);此项不表示已发布。 - [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与权限边界。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。 - [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。 - [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。 diff --git a/build/run-obsidian-electron-acceptance.cjs b/build/run-obsidian-electron-acceptance.cjs new file mode 100644 index 00000000..e79beed6 --- /dev/null +++ b/build/run-obsidian-electron-acceptance.cjs @@ -0,0 +1,191 @@ +const { dirname, join, resolve } = require('node:path') +const { copyFileSync, mkdtempSync, mkdirSync, rmSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { pathToFileURL } = require('node:url') +const assert = require('node:assert/strict') + +if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE === '1') { + const { spawnSync } = require('node:child_process') + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-obsidian-ui-')) + const env = { ...process.env, GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT: root } + if (process.argv.includes('--live') || process.argv.includes('--chat')) { + env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE = process.argv.includes('--live') ? '1' : '0' + env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT = process.argv.includes('--chat') ? '1' : '0' + env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY = process.argv.includes('--continue-only') ? '1' : '0' + require('esbuild').buildSync({ entryPoints: ['tests/obsidian-runtime-acceptance.ts'], + outfile: join(root, 'runtime.cjs'), bundle: true, external: ['electron', 'ssh2', 'node-pty', 'koffi'], platform: 'node', format: 'cjs', + banner: { js: `require = require('node:module').createRequire(${JSON.stringify(resolve('package.json'))});` } }) + } + delete env.ELECTRON_RUN_AS_NODE + try { + const result = spawnSync(require('electron'), [__filename], { + env, stdio: 'inherit', timeout: 300_000 + }) + if (result.error) throw result.error + process.exitCode = result.status ?? 1 + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) + } +} else { + const { app, BrowserWindow } = require('electron') + const root = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT + assert(root) + const profile = join(root, 'profile') + mkdirSync(profile) + if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1' || process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1') { + const settings = process.env.GOODBUDDY_ACCEPTANCE_SETTINGS ?? join(app.getPath('appData'), 'goodbuddy', 'runtime-settings.json') + copyFileSync(join(dirname(settings), 'Local State'), join(profile, 'Local State')) + } + mkdirSync(join(root, 'Temporary Vault')) + app.setPath('userData', profile) + app.setAppPath(resolve('.')) + const timer = setTimeout(() => { console.error('Acceptance timed out'); app.exit(1) }, 280_000) + const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + ;(async () => { + const chat = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1' + ? await require(join(root, 'runtime.cjs')).prepareChat(root, profile) : undefined + await import(pathToFileURL(resolve('out/main/index.js')).href) + let window + for (let attempt = 0; attempt < 120; attempt++) { + window = BrowserWindow.getAllWindows().find(item => item.webContents.getURL().includes('index.html')) + if (window && await window.webContents.executeJavaScript('Boolean(document.querySelector("button"))')) break + await wait(250) + } + assert(window, 'Production main must open its BrowserWindow') + assert.equal(app.getPath('userData'), profile) + const preferences = window.webContents.getLastWebPreferences() + assert.equal(preferences.contextIsolation, true) + assert.equal(preferences.nodeIntegration, false) + const js = code => window.webContents.executeJavaScript(code).catch(error => { throw new Error(`${code}: ${error.message}`) }) + const click = async expression => { + window.focus() + for (let i=0;i<100;i++) { if (await js(`Boolean(${expression})`)) break; await wait(100) } + const point = await js(`(() => { const element = ${expression}; if (!element) throw new Error('Missing control'); element.scrollIntoView({block:'center'}); const r=element.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`) + window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) + await wait(500) + } + if (chat) { + await js(`(async()=>{const p=(await window.goodbuddy.projects.list())[0];await window.goodbuddy.projects.update(p.id,{name:p.name,description:p.description,rootPath:${JSON.stringify(join(root,'chat-workspace'))},defaultWorkMode:'execute',runtimeSelection:{provider:'opencode'}})})()`) + window.webContents.reload() + await wait(1000) + } + await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`) + await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`) + await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`) + const card = `Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')` + const poll = async (expression, attempts=100) => { + for (let i=0;i({text:e.innerText,selected:e.getAttribute('aria-selected')})))`)) + throw new Error(`Timed out: ${expression}`) + } + await poll(`Boolean(${card})`) + assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false) + await click(`(${card}).querySelector('select')`) + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'End' }) + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Return' }) + window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Return' }) + await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`) + const input = `(${card}).querySelector('input:not([type="checkbox"])')` + const vault = join(root, 'Temporary Vault') + await click(input) + await window.webContents.insertText(vault) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`) + await poll(`Boolean((${card}).querySelector('[role="status"]'))`) + assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`), /18/) + assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, '') + assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`) + await poll(`!(${card}).querySelector('.primary-button').disabled`) + assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault) + await click(input) + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] }) + window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] }) + await window.webContents.insertText(join(root, 'missing-vault')) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`) + await poll(`Boolean((${card}).querySelector('[role="alert"]'))`) + assert.equal(await js(`(${input}).value`), join(root, 'missing-vault')) + assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault) + console.log('PASS: native UI draft test (18 tools), no implicit save/enable, save through IPC, missing-folder error preserves draft') + if (process.platform === 'win32') { + await click(`(${card}).querySelectorAll('.capability-card__actions button')[0]`) + const user32 = require('koffi').load('user32.dll') + const getWindow = user32.func('uintptr_t __stdcall GetWindow(uintptr_t hwnd, uint32_t command)') + const getPid = user32.func('uint32_t __stdcall GetWindowThreadProcessId(uintptr_t hwnd, _Out_ uint32_t *pid)') + const postMessage = user32.func('bool __stdcall PostMessageW(uintptr_t hwnd, uint32_t message, uintptr_t wparam, intptr_t lparam)') + const handle = Number(window.getNativeWindowHandle().readBigUInt64LE()) + const popup = getWindow(handle, 6) + assert(popup && Number(popup) !== handle, 'Native folder dialog must open') + const pid = [0] + getPid(popup, pid) + assert.equal(pid[0], process.pid, 'Only close this acceptance process dialog') + assert(postMessage(popup, 0x10, 0, 0)) + await poll(`!(${card}).querySelector('.primary-button').disabled`) + assert.equal(await js(`(${input}).value`), join(root, 'missing-vault')) + console.log('PASS: real Windows folder dialog opened and cancelled without changing draft') + } + window.setMinimumSize(640, 480) + window.setSize(760, 720) + await wait(500) + const geometry = await js(`(() => {const c=${card};const r=c.getBoundingClientRect();return {width:innerWidth,left:r.left,right:r.right,scroll:c.scrollWidth,client:c.clientWidth}})()`) + assert(geometry.scroll <= geometry.client + 1 && geometry.right <= geometry.width && geometry.left >= 0, JSON.stringify(geometry)) + console.log('PASS: narrow card has no horizontal overflow', JSON.stringify(geometry)) + await click(input) + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] }) + window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] }) + await window.webContents.insertText(vault) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`) + await poll(`Boolean((${card}).querySelector('[role="status"]'))`) + await js(`(${card}).querySelector('[role="switch"]').focus()`) + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' }) + window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' }) + await poll(`(${card}).querySelector('[role="switch"]').checked && !(${card}).querySelector('[role="switch"]').disabled`) + await click(`(${card}).querySelector('button[aria-expanded]')`) + await poll(`(${card}).querySelectorAll('.mcp-server-tools li').length === 19`) + console.log('PASS: keyboard switch enables Obsidian; expanded production UI lists 19 tools') + for (const theme of ['light', 'dark']) { + await js(`document.documentElement.dataset.theme=${JSON.stringify(theme)}`) + const bounds = await js(`(()=>{const c=${card};const r=c.getBoundingClientRect();return {right:r.right,width:innerWidth,scroll:c.scrollWidth,client:c.clientWidth,background:getComputedStyle(c).backgroundColor}})()`) + assert(bounds.right <= bounds.width && bounds.scroll <= bounds.client + 1) + console.log(`PASS: ${theme} narrow Obsidian bounds`, JSON.stringify(bounds)) + } + if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1') { + await require(join(root, 'runtime.cjs')).run(root, await js('window.goodbuddy.capabilities.getSnapshot()')) + } + if (chat) { + await click(`document.querySelector('.settings-panel__header button')`) + window.setSize(1280, 850) + try { + const providers = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY === '1' ? ['Continue'] : ['OpenCode', 'Continue'] + for (const provider of providers) { + if (provider === 'Continue') { + await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`) + await js(`document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').click()`) + await click(`Array.from(document.querySelectorAll('.runtime-picker__menu strong')).find(b=>b.innerText==='Continue Runtime')?.nextElementSibling`) + } + await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`) + const mode = `document.querySelector('button[aria-label^="\u5de5\u4f5c\u6a21\u5f0f"]')` + if (!(await js(`(${mode}).innerText.includes('Execute')`))) { + await click(mode) + await click(`Array.from(document.querySelectorAll('[role="menuitemradio"]')).find(b=>b.innerText.startsWith('Execute'))`) + } + const marker = `CHAT_${provider}_${require('node:crypto').randomUUID()}` + const before = chat.observations.length + await click(`document.querySelector('textarea')`) + await window.webContents.insertText(`Use only Obsidian tools. Write ${provider}-chat.md containing ${marker} using obsidian_write_note, then read it using obsidian_read_note. Reply with the content read. Do not use other tools.`) + await click(`document.querySelector('button[aria-label="\u53d1\u9001"]')`) + await poll(`Array.from(document.querySelectorAll('.message--assistant')).some(e=>e.innerText.includes(${JSON.stringify(marker)}))`,450) + await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`,450) + assert((require('node:fs').readFileSync(join(vault,`${provider}-chat.md`),'utf8')).includes(marker)) + console.log(JSON.stringify({ provider, fullChatUi: 'passed', realModelHttpRequests: chat.observations.length-before })) + } + } finally { + console.log(JSON.stringify({ chatModelHttpRequests: chat.observations.length })) + await chat.close() + } + } + await wait(1000) + clearTimeout(timer) + app.exit(0) + })().catch(error => { console.error(error); clearTimeout(timer); app.exit(1) }) +} diff --git a/build/run-obsidian-packaged-acceptance.cjs b/build/run-obsidian-packaged-acceptance.cjs new file mode 100644 index 00000000..9f328dc7 --- /dev/null +++ b/build/run-obsidian-packaged-acceptance.cjs @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { mkdtemp, mkdir, readFile, readdir, writeFile, rm, stat } = require('node:fs/promises') +const { tmpdir } = require('node:os') +const { join, resolve } = require('node:path') + +async function main() { + const executable = resolve(process.argv[2]) + await stat(executable) + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-packaged-')) + const profile = join(root, 'profile') + const vault = join(root, 'Packaged Vault') + await mkdir(profile) + await mkdir(vault) + const audit = join(root, 'node-audit.jsonl') + const guard = join(root, 'offline.cjs') + await writeFile(guard, ` +const fs = require('node:fs'); +const net = require('node:net'); +const original = net.Socket.prototype.connect; +net.Socket.prototype.connect = function(...args) { + const options = net._normalizeArgs(args)[0]; + const host = options.host || 'localhost'; + if (!options.path && !['localhost','127.0.0.1','::1'].includes(host)) throw new Error('Offline acceptance blocked network'); + return original.apply(this,args); +}; +globalThis.fetch = async () => { throw new Error('Offline acceptance blocked fetch'); }; +require('node:module').syncBuiltinESMExports(); +fs.appendFileSync(${JSON.stringify(audit)}, JSON.stringify({entry:process.argv[1],exe:process.execPath,electron:process.versions.electron})+'\\n'); +`) + const env = { ...process.env, NODE_OPTIONS: `--require ${JSON.stringify(guard)}`, + PATH: join(process.env.SystemRoot, 'System32') } + delete env.Path + delete env.ELECTRON_RUN_AS_NODE + const child = spawn(executable, [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--inspect=0', + '--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=<-loopback>'], { env, stdio: ['ignore', 'ignore', 'pipe'] }) + let diagnostic = '' + child.stderr.on('data', chunk => { diagnostic += chunk.toString() }) + const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + let socket + let mainSocket + try { + let port + for (let i=0;i<100;i++) { + try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break } catch { await wait(200) } + } + assert(port, 'Packaged app must expose debugging only in the isolated profile') + const mainEndpoint = diagnostic.match(/ws:\/\/127\.0\.0\.1:\d+\/[-a-f0-9]+/u)?.[0] + assert(mainEndpoint, 'The isolated Main inspector must be available') + mainSocket = new WebSocket(mainEndpoint) + await new Promise((resolve, reject) => { mainSocket.onopen=resolve;mainSocket.onerror=reject }) + // Packaged Electron strips NODE_OPTIONS at startup. Restore it only for this + // disposable Main so its real MCPVault child receives the offline guard. + await new Promise((resolve,reject) => { + const timer=setTimeout(()=>reject(new Error('Main inspector timed out')),10_000) + mainSocket.onmessage=message=>{const value=JSON.parse(message.data);if(value.id===1){clearTimeout(timer);if(value.error || value.result?.exceptionDetails)reject(new Error('Unable to set child network guard'));else resolve()}} + mainSocket.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:`process.env.NODE_OPTIONS=${JSON.stringify(env.NODE_OPTIONS)}`}})) + }) + mainSocket.close() + let target + for (let i=0;i<100;i++) { + target = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find(item => item.type === 'page' && item.url.includes('index.html')) + if (target) break + await wait(200) + } + assert(target, 'Packaged production renderer must load') + socket = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject }) + let id = 0 + const pending = new Map() + socket.onmessage = message => { const value = JSON.parse(message.data); if (pending.has(value.id)) { const {resolve,reject}=pending.get(value.id); pending.delete(value.id); if (value.error) reject(new Error(value.error.message)); else resolve(value.result) } } + const send = (method, params={}) => new Promise((resolve,reject) => { + const key=++id + const timer=setTimeout(()=>{pending.delete(key);reject(new Error(`CDP timed out: ${method}`))},10_000) + pending.set(key,{ + resolve: value=>{clearTimeout(timer);resolve(value)}, + reject: error=>{clearTimeout(timer);reject(error)} + }) + socket.send(JSON.stringify({id:key,method,params})) + }) + const js = async expression => { const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!result.exceptionDetails,JSON.stringify(result.exceptionDetails));return result.result.value } + const poll = async expression => { for(let i=0;i<100;i++){ if(await js(expression))return;await wait(200) } throw new Error(`Timeout: ${expression}`) } + const click = async expression => { + await js(`(()=>{const e=${expression};if(!e)throw new Error('Missing control');e.scrollIntoView({block:'center'});e.focus();e.click()})()`) + await wait(500) + } + await poll('Boolean(window.goodbuddy && document.querySelector("button"))') + await stat(join(profile,'assistant.sqlite')) + assert.equal(await js('typeof require'), 'undefined') + await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`) + await poll(`Array.from(document.querySelectorAll('button')).some(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`) + await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`) + await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`) + const card=`Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')` + await poll(`Boolean(${card})`) + await js(`{const e=(${card}).querySelector('select');e.value='folder';e.dispatchEvent(new Event('change',{bubbles:true}));}`) + await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`) + await click(`(${card}).querySelector('input:not([type="checkbox"])')`) + await send('Input.insertText',{text:vault}) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`) + await poll(`Boolean((${card}).querySelector('[role="status"]'))`) + assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`),/18/) + await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`) + await poll(`!(${card}).querySelector('.primary-button').disabled`) + assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath,vault) + const bin=join(profile,'tool-environment','bin') + const shims=(await readdir(bin,{recursive:true})).filter(name=>name.endsWith('node.cmd')) + assert(shims.length>0) + assert((await readFile(join(bin,shims[0]),'utf8')).includes(executable)) + console.log('PASS: packaged app.asar UI -> real IPC -> packaged MCPVault (18 tools), save, managed packaged Electron shim, PATH=System32, Chromium dead proxy') + const records=(await readFile(audit,'utf8')).trim().split('\n').map(line=>JSON.parse(line)) + assert(records.some(item=>item.entry?.includes('app.asar.unpacked') && item.entry.endsWith('server.js') && item.exe===executable && item.electron)) + console.log('PASS: packaged MCPVault child loaded Node network guards; audited packaged executable and unpacked server entry') + console.log('Boundary: MCPVault Node API guards + Chromium proxy; no OS firewall, physical disconnection, or installer execution') + } finally { + socket?.close() + mainSocket?.close() + if (child.exitCode === null) { + const killer=spawn('taskkill',['/PID',String(child.pid),'/T','/F'],{stdio:'ignore'}) + await new Promise(resolve=>killer.once('exit',resolve)) + } + await rm(root,{recursive:true,force:true,maxRetries:10,retryDelay:200}) + } +} +main().catch(error=>{console.error(error);process.exitCode=1}) diff --git a/docs/features/obsidian/README.md b/docs/features/obsidian/README.md new file mode 100644 index 00000000..62699ade --- /dev/null +++ b/docs/features/obsidian/README.md @@ -0,0 +1,32 @@ +# Obsidian 本机仓库接入 + +GoodBuddy 通过内置 MCPVault 访问本机 Obsidian 仓库,供会话读取、搜索和修改笔记。功能默认关闭;默认范围为本机已注册的全部仓库,也可指定一个文件夹。接入直接访问本地文件,不要求 Obsidian 正在运行或安装插件。 + +当前源码已实现主服务、设置 IPC、HTTP 网关和直连工具提供器。自动化覆盖 19 个共享工具;Windows 真实 Electron 设置操作、原生目录对话框取消、键盘开关、浅深主题窄窗口,以及 OpenCode / Continue 从聊天输入框发起的实际笔记读写均已通过。Windows x64 解包应用也通过设置到随包 MCPVault 的连接与保存验证,测试限制了子进程 Node 网络 API 和 Chromium 代理。物理断网、安装器及其他目标系统仍待验收;命令、模型调用次数和全量检查阻塞见[进度](./progress.md)。 + +## 文档职责 + +| 文档 | 唯一职责 | +| --- | --- | +| [产品需求](./prd.md) | 用户问题、范围、稳定需求 ID 和产品验收 | +| [用户故事](./user-stories.md) | 使用场景及 Given/When/Then 验收 | +| [功能逻辑](./logic-design.md) | 范围决策、状态转换、失败和取消规则 | +| [技术设计](./technical-design.md) | 模块、契约、打包、验证,以及独立章节中的 UI 设计 | +| [进度](./progress.md) | 已核对的实现事实、剩余工作和验证记录 | + +界面仅在现有 MCP 设置中增加一个卡片,因此不另设 `ui-design.md`。技术设计的“UI 设计”章节承担信息结构、控件、反馈、无障碍和窄窗口适配职责;全局视觉规范仍由 [UI-DESIGN.md](../../../UI-DESIGN.md)定义。 + +## 术语与依赖 + +| 术语 | 含义 | +| --- | --- | +| 仓库(vault) | 一个本机笔记文件夹;显示名称不作为调用身份 | +| 注册仓库 | 本机 Obsidian 的 `obsidian.json` 中登记的仓库,包含当前未打开的仓库 | +| `vaultPath` | 保存的范围设置;空字符串代表注册仓库集合,非空代表指定文件夹 | +| `vaultId` | 一次工具调用选择的仓库标识,与文件相对路径分开传递 | +| MCPVault | npm 包 `@bitbonsai/mcpvault`,本功能固定使用 `0.16.0` | +| 草稿 | 设置页面尚未保存的范围,可单独测试 | + +共享目录包含 `obsidian_list_vaults` 和 18 个加 `obsidian_` 前缀的上游工具。连接测试显示的上游工具数仍为 18。保存范围、切换开关或修改分配会撤销此前含 Obsidian 的请求授权,规则见[功能逻辑](./logic-design.md#授权撤销与清理)。 + +Node 启动环境复用[工具执行环境](../local-tool-environment/README.md),不建立第二套解释器安装或下载配置。接入目标为直连模型、GoodBuddy 管理的本机 OpenCode 和 Continue;DeepSeek Harness 与远程执行路径沿用现有不可用边界。本功能的仓库位于桌面所在设备。 diff --git a/docs/features/obsidian/logic-design.md b/docs/features/obsidian/logic-design.md new file mode 100644 index 00000000..81dffd16 --- /dev/null +++ b/docs/features/obsidian/logic-design.md @@ -0,0 +1,60 @@ +# 功能逻辑 + +## 状态维度 + +启用状态、Runtime 分配、已保存范围和页面草稿分别管理。启用状态不表示服务已连接,测试成功也不表示会话可调用。会话使用已保存范围;测试使用本次提交的草稿。 + +测试状态为未测试、测试中、成功或失败。修改草稿使先前测试结果失效;测试结果只描述该次请求,不持久化为服务健康状态。 + +## 规则与追溯 + +| ID | 规则 | 需求 / 故事 | +| --- | --- | --- | +| LR-1 | 仅启用操作改变启用状态;仅保存操作改变持久化范围;测试不改变二者。 | FR-1、FR-7;US-1~US-3 | +| LR-2 | 非空指定路径优先,完全绕过注册信息;空路径读取全部注册条目,忽略条目的打开状态。 | FR-2、FR-6;US-1、US-2、US-6 | +| LR-3 | 先解析当前范围,再校验 `vaultId`,成功后才启动目标仓库的工具进程。 | FR-3;US-5 | +| LR-4 | 上游能力完整保留;启用、分配和应用现有工作模式由既有会话入口执行,不另加 Obsidian 权限层。 | FR-4、FR-9;US-4、US-9~US-11 | +| LR-5 | 范围解析失败使本次请求失败,不跳过失效注册条目,不选择替代仓库。 | FR-8;US-7 | +| LR-6 | 测试仅发现仓库并列举工具,逐仓库连接;工具数按上游名称去重,不按仓库数相乘。 | FR-7;US-1、US-2 | +| LR-7 | 操作结束或取消后关闭本次 MCP 连接和子进程;取消不承诺撤销已落盘写入。 | FR-8;US-8 | +| LR-8 | 保存范围、切换开关和修改分配在输入校验后、持久化前撤销含 Obsidian 的旧请求授权并取消在途调用;网关销毁等待活动调用清理。 | FR-10;US-12 | + +## 仓库决策 + +| 条件 | 结果 | +| --- | --- | +| 指定路径非空 | 验证为可访问的绝对目录,得到单仓库范围 | +| 默认范围且注册信息有效 | 使用全部注册条目,保留各自注册 ID | +| 注册文件不可读、无条目、条目无路径或任一目录不可访问 | 返回对应错误;用户可修复注册信息或选择文件夹 | +| 范围只有一个仓库且请求省略 ID | 使用唯一仓库 | +| 范围超过一个仓库且请求省略 ID | 拒绝调用,要求明确 ID | +| 请求提供 ID 且属于当前范围 | 使用该 ID 对应仓库 | +| 请求 ID 不属于当前范围 | 拒绝调用,不回退到唯一仓库或首个仓库 | + +工具清单描述与实际文件操作分开。服务可用首个仓库读取固定版本的工具 schema,这不赋予首个仓库任何默认写入地位。跨仓库搜索或修改由调用方对明确的仓库 ID 分别发起请求,不把一次请求广播到全部仓库。 + +## 设置转换 + +| 动作 | 草稿与结果 | 持久化影响 | +| --- | --- | --- | +| 选择文件夹 | 更新草稿,切换为指定文件夹,清除旧测试结果 | 无 | +| 取消文件夹选择 | 保留原草稿 | 无 | +| 切换范围或编辑路径 | 清除旧结果和关联错误;指定文件夹模式空路径时报错 | 无 | +| 测试 | 使用提交时的草稿;忙碌期间禁止重复提交,结束后显示结果或错误 | 无 | +| 保存成功 | 使用返回快照显示已保存范围,结束当前草稿编辑状态 | 仅范围 | +| 保存失败 | 保留草稿并显示错误,可修改后重试 | 已保存范围不变 | +| 启用、关闭或调整分配 | 更新对应状态,草稿不自动保存 | 仅对应状态 | + +上表仅列持久化影响;涉及授权的副作用按 LR-8 执行。连接测试不提供独立取消按钮,其临时连接不持有会话授权。 + +## 授权撤销与清理 + +Main 对范围保存、开关和分配请求先验证来源及输入,再撤销现存的含 Obsidian 授权,随后保存设置。提交相同值也触发撤销。输入无效时在撤销前返回错误;持久化失败时原设置保留,但旧授权已经失效。草稿编辑、文件夹选择和连接测试均不触发此撤销。 + +撤销按整个请求授权执行。若同一个授权同时含 Obsidian 和知识库等能力,该授权整体失效;独立的不含 Obsidian 授权保留。后续请求按当前保存的范围、开关、分配和工作模式重新取得授权;关闭后不再签发 Obsidian 能力。 + +撤销会立即移除旧授权并发出取消信号,设置响应不等待子进程完全退出。工具调用负责在结束路径关闭 transport 和 client;网关销毁则等待活动调用完成清理,包含先前已撤销但仍在关闭的调用。取消过程中已落盘的内容保留。 + +## 完整性检查 + +规则已覆盖首次使用、草稿测试、保存、范围恢复、单仓库、多仓库、失效路径、旧配置、工作模式,以及设置变更对旧授权和在途调用的影响。当前没有待定的产品决策。实现及各层自动化、Electron 与安装包验证状态见[进度](./progress.md)。 diff --git a/docs/features/obsidian/prd.md b/docs/features/obsidian/prd.md new file mode 100644 index 00000000..cdc3436e --- /dev/null +++ b/docs/features/obsidian/prd.md @@ -0,0 +1,36 @@ +# 产品需求 + +## 用户问题与目标 + +用户需要在会话中查找本机已有的 Obsidian 笔记,并将整理结果写回指定仓库。手动配置 Node、MCP 命令或 Obsidian 插件会增加首次使用步骤。接入应随应用提供依赖,支持先测试范围,再决定保存和启用。 + +## 功能需求 + +以下 ID 在后续实施中保持稳定。场景验收见[用户故事](./user-stories.md),行为决策见[功能逻辑](./logic-design.md)。 + +| ID | 产品要求 | +| --- | --- | +| FR-1 | 首次使用及旧设置缺少 Obsidian 字段时默认关闭。保存范围、测试连接和启用是独立操作,不互相隐式触发。 | +| FR-2 | 默认 `vaultPath` 为空,访问本机已注册的全部仓库,包含未打开的仓库。用户可改选一个本机文件夹,也可恢复默认范围。 | +| FR-3 | 工具调用使用仓库 ID 定位。可用仓库超过一个时必须明确指定 `vaultId`;缺失或无效时返回错误,不按顺序、名称或最近使用情况选择写入目标。 | +| FR-4 | 提供 MCPVault 0.16.0 的全部 18 个上游工具及其参数能力。不得增加 Obsidian 专用只读设置、工具过滤或额外审批;应用现有 Ask/Execute 工作模式照常生效。 | +| FR-5 | MCPVault 及完整运行依赖随安装包交付,使用现有托管 Node,首次调用无需联网安装 npm 包,也无需用户安装系统 Node。 | +| FR-6 | 不依赖正在运行的 Obsidian、社区插件、REST 服务或 API 密钥。指定文件夹可绕过注册信息,不要求该文件夹已在 Obsidian 注册。 | +| FR-7 | 功能关闭时也可测试未保存的草稿。测试返回仓库名称、路径和上游工具数,不修改笔记、不保存范围、不启用功能。 | +| FR-8 | 范围无效、仓库不可访问、内置依赖缺失或工具执行失败时说明原因并保留草稿。取消文件夹选择不清空输入;会话调用取消后释放对应子进程。 | +| FR-9 | 沿用内置 MCP 的启用与 Runtime 分配方式。设置范围属于桌面端全局配置,具体会话仍受既有能力分配和工作模式约束。 | +| FR-10 | 提交保存范围、切换 Obsidian 开关或修改分配时,撤销此前含 Obsidian 的请求授权并取消其在途调用,避免继续使用旧范围或旧分配。应用关闭须等待活动工具清理;不回滚已经落盘的修改。 | + +“离线”指本地 MCP 服务的启动和文件操作不需要下载依赖;模型服务是否联网由模型配置决定。工具返回的笔记内容会进入当前会话处理流程,不能将本地接入描述为模型推理也完全离线。 + +## 范围与非目标 + +本期接入原有仓库文件,不复制为 GoodBuddy 知识库,不提供同步服务、仓库管理器或 Obsidian 编辑器。附件相关能力按上游工具提供的范围开放,不承诺未包含在上游中的附件编辑能力。文件删除等参数沿用上游语义;上游参数校验不构成新增产品审批。 + +注册仓库发现不扫描整块磁盘,不推断最近活动仓库。接入支持直连模型、GoodBuddy 管理的本机 OpenCode 和 Continue;DeepSeek Harness 和远程执行路径保持不可用。远程 Host 仓库发现、跨设备同步和仓库迁移不在本期范围。 + +## 产品验收 + +发布前须完成[技术设计中的验证矩阵](./technical-design.md#验证与实施顺序),并在[进度](./progress.md)记录真实结果。验收至少覆盖:关闭状态测试草稿、保存后重新进入设置、恢复全部仓库、多仓库明确选择、实际读取和写回、工作模式约束,以及安装包断网启动。 + +18 个上游工具按名称和参数契约逐项比对;加上仓库发现,共有 19 个共享工具。自动化验证分别覆盖注册 IPC、HTTP 网关和直连工具提供器,安装包及完整 Electron 界面仍单独验收。配置提交时还须验证旧授权立即失效、在途调用收到取消,以及网关销毁等待子进程清理完成。 diff --git a/docs/features/obsidian/progress.md b/docs/features/obsidian/progress.md new file mode 100644 index 00000000..ff793678 --- /dev/null +++ b/docs/features/obsidian/progress.md @@ -0,0 +1,109 @@ +# 实施进度 + +## 当前状态 + +2026-09-27:主服务、设置 UI、Main IPC、HTTP 网关和直连工具提供器已实现,共享目录共 19 个工具。Windows 真实 Electron 设置操作与 OpenCode / Continue 聊天输入框读写已通过;Windows x64 解包应用通过受限网络下的设置连接和保存验证。物理断网、安装器及其他系统验收仍待完成,全量检查存在下述无关失败;当前状态不表示已发布。 + +| 阶段 | 已核对事实 | 剩余工作 | +| --- | --- | --- | +| TS-1 配置与服务 | 默认关闭、范围独立保存、注册发现及 ID 路由已实现;复制后的真实 MCPVault 已用于文件操作和错误传播测试 | 跨平台安装包验证归入 TS-4 | +| TS-2 设置 UI | 真实 Electron 窗口中验证草稿测试不保存、不启用,保存和错误保留输入,Windows 原生目录对话框取消、键盘开关、19 工具展开及浅深主题窄窗口;修复表单横向撑宽 | 其他系统原生窗口验收 | +| TS-3 网关与 IPC | 19 工具、Ask/Execute、撤销、取消及清理自动化通过;OpenCode / Continue 真实会话及聊天输入框读写通过 | 安装后的聊天读写与其他系统验收 | +| TS-4 打包与交付 | 当前源码 `build:bundle` 和 Windows x64 `--dir` 打包通过;实际 `app.asar` UI 连接随包 MCPVault 并保存,子进程加载网络 API 限制,PATH 仅含 System32 | 物理断网或 OS 级隔离、安装器、macOS/Linux/其他架构原生资源验收 | + +DeepSeek Harness 和远程执行保持既有不可用边界。本轮产品代码仅修改 Obsidian 表单布局,没有改变 Agent 或远程 Runtime 路径。 + +## 提交快照验证 + +提交前从 HEAD 与 Obsidian 暂存内容生成独立目录,排除并行开发的原生终端、DSH 升级、IPC 拆分与 Magic Notes 改动。锁文件从 HEAD 重新生成,仅添加固定版本 MCPVault 及其依赖,没有升级或删除既有依赖项;独立目录安装该锁文件后验证。 + +`npm run typecheck`、`npm run lint` 和 `npm run build` 均通过。聚焦服务、Electron、网关、直连工具、配置、Continue 适配器及共享 UI 的 11 个文件共 237 项通过;完整 IPC、设置面板与构建测试的 3 个文件共 303 项通过。合计 14 个文件、540 项测试通过。本次没有重跑全量套件、安装包或真实模型调用,新增模型调用为 0。下文全仓失败记录来自此前混合工作区,不代表此提交快照的类型检查或构建结果。 + +## 2026-09-27 验收补充 + +以下结果来自独立临时应用配置、项目和 vault。模型凭据通过临时复制的 `runtime-settings.json` 与配套 `Local State` 在 Electron Main 中解密;源配置和用户 vault 均未修改。测试结束删除临时配置、凭据副本和笔记;Windows 解包构建与检查日志保留在系统临时目录的 `opencode` 下。没有提交、发布、安装器或 portable 构建。 + +| 验证项 | 实际结果与边界 | +| --- | --- | +| 聚焦测试 | 8 文件、133 项通过,含 `obsidian-electron.test.ts` 和 Continue 适配器;IPC 定向 5 项通过、152 项跳过。布局修正后 MCP 设置、共享 UI 与 Electron 服务共 43 项通过 | +| `npm run build` / `npm run typecheck` | `build` 停在类型检查;末次类型检查仍为 SSH pool、SSH directory browser 和 SSH terminal 测试替身缺少 `forwardLoopback`。本轮脚本类型错误已修复;未修改并行开发的 SSH 测试 | +| `npm run build:bundle` | 当前源码通过,布局修改后再次通过;不能据此将 `npm run build` 记为通过 | +| `npm run lint` | 当前工作区通过 | +| 分项类型检查 | `npx tsc --noEmit -p tsconfig.web.json` 与 `npx tsc --noEmit -p tsconfig.agent.json` 均通过 | +| `npm test` | 首次 200 秒超时;随后后台运行完整结束,691.25 秒,444 文件通过、3 失败、11 跳过;5,274 测试通过、3 失败、83 跳过 | +| 全量失败定位 | `activity-history-io.test.ts`:迁移重复添加 `source_json`;`magic-notes-capture.electron.test.ts`:宽度 0、溢出 82 的布局断言;`build-release.test.ts`:fixture 缺少 `@deepseek-ai/dsh/package.json`。均属于并行改动范围,本轮保留原状 | +| 真实设置 UI | [Electron runner](../../../build/run-obsidian-electron-acceptance.cjs) 导入生产 Main、使用生产 Preload/Renderer 和原生鼠标键盘,覆盖测试 18 个上游工具、保存、错误、目录对话框取消、Space 启用和展开 19 个工具。未替换 IPC 或 ObsidianService | +| 布局缺陷与修复 | 原表单复用了横向 `.capability-diagnostic`;759 像素内容视口中卡片右边界达到 1116 像素。改为 Obsidian 纵向表单、按钮及路径换行后,普通状态右边界 691,含测试结果和工具展开时 732;浅深主题均无卡片内部横向溢出 | +| 真实 Runtime 会话 | [Runtime runner](../../../tests/obsidian-runtime-acceptance.ts) 使用真实 OpenCode / Continue、生产 HTTP 网关和 MCPVault,断言工具事件、模型返回和实际文件。两轮各 Runtime 各 3 次模型 HTTP 请求,共 12 次;第二轮使用 UI 保存并启用的能力快照 | +| 完整聊天输入框 | `--chat` 中 OpenCode 实际发送、读写及回复验证通过,计 4 次模型 HTTP 请求;该次随后在 Continue 菜单定位处失败。修正选择器后 `--chat --continue-only` 通过,同样 4 次请求。合计本轮 20 次真实模型 HTTP 请求,其他启动、菜单和凭据准备失败均为 0 次。聊天证据来自开发生产 bundle;没有将独立 Runtime harness 冒充聊天 UI | +| Windows x64 解包应用 | 正常 electron-builder 配置以 `--dir --publish never` 生成 `GoodBuddy.exe` 和 `app.asar`,含解包 MCPVault 及 `windows-trash.exe`。[打包 runner](../../../build/run-obsidian-packaged-acceptance.cjs) 通过 CDP 驱动真实页面控件,设置连接返回 18 工具,保存快照匹配临时 vault;托管 node shim 指向该打包 EXE | +| 网络限制 | 打包 EXE 的 PATH 仅含 System32,Chromium 指向不可用代理;Electron 启动会移除 `NODE_OPTIONS`,首次尝试未加载 guard。随后仅在测试 Main 的本机 inspector 中恢复该变量,审计确认真实 MCPVault 子进程加载 guard,入口为 `app.asar.unpacked/.../server.js`。Node guard 拒绝非 loopback socket 和 fetch;不覆盖 OS 全部联网方式,不算物理断网或防火墙隔离 | + +复现命令如下。打包命令须使用真正的 Node 可执行文件;本机 PATH 中的 Electron Node shim 会使 electron-builder CLI 误读 argv。`--live` 和 `--chat` 会调用真实文本模型;默认读取当前用户 GoodBuddy 模型配置,也可用 `GOODBUDDY_ACCEPTANCE_SETTINGS` 指向含配套 `Local State` 的测试配置目录中的设置文件。 + +```powershell +npx vitest run src/shared/obsidian-tools.test.ts src/main/obsidian/obsidian-service.test.ts src/main/obsidian/obsidian-electron.test.ts src/main/agent/knowledge-mcp-gateway.obsidian.test.ts src/main/agent/model-tool-provider.obsidian.test.ts src/main/capabilities/capability-service.test.ts src/renderer/src/McpSettingsSection.test.tsx src/main/agent/continue-host-adapter.test.ts +npx vitest run src/main/ipc.test.ts -t Obsidian +npx vitest run src/renderer/src/McpSettingsSection.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx src/main/obsidian/obsidian-electron.test.ts +npm run build:bundle +node build/run-obsidian-electron-acceptance.cjs +node build/run-obsidian-electron-acceptance.cjs --live +node build/run-obsidian-electron-acceptance.cjs --chat +node build/run-obsidian-electron-acceptance.cjs --chat --continue-only +# 以下占位路径替换为本机 Node、临时构建目录及打包 EXE。 +& "" build/run-electron-builder.cjs --win --x64 --dir --publish never --config.directories.output="" +node build/run-obsidian-packaged-acceptance.cjs "/win-unpacked/GoodBuddy.exe" +``` + +本机打包输出为 `%TEMP%/opencode/obsidian-package-acceptance/win-unpacked`;全量日志为 `%TEMP%/opencode/obsidian-full-tests.log` 与 `.err`。以下保留 2026-09-26 的历史记录,不能用其中旧阻塞覆盖本节最新结果。 + +本轮文档 deai-writing 扫描 6 文件,0 个阻断项、12 个复核项;复核项均为测试条件、状态或能力范围限定,通读后保留。`git diff --check` 通过。 + +## 源码与测试证据 + +2026-09-26 核对以下源码与测试。本轮重新执行的结果见验证记录;此前构建结果保留原来源,不作为当前工作区构建通过的证明。 + +| 证据 | 可支持的结论 | +| --- | --- | +| [obsidian-service.ts](../../../src/main/obsidian/obsidian-service.ts) 与[服务测试](../../../src/main/obsidian/obsidian-service.test.ts) | 注册发现、真实复制包后的读写、许可证与资源、工具集合、多仓库 ID 以及取消后进程清理 | +| [capability-service.test.ts](../../../src/main/capabilities/capability-service.test.ts) | 范围持久化、旧字段缺省和拒绝 `readOnly` 参数 | +| [ipc.test.ts](../../../src/main/ipc.test.ts) | 设置组件调用正式注册的 IPC 处理器、真实配置服务及 MCPVault;Electron 传输和目录对话框由测试替代,不是安装后完整界面验证 | +| [共享工具测试](../../../src/shared/obsidian-tools.test.ts) | 19 工具集合和 18 个上游工具的字段、默认值、必填项与约束对应 | +| [HTTP 网关测试](../../../src/main/agent/knowledge-mcp-gateway.obsidian.test.ts) | 实际 HTTP MCP 发现 19 工具,真实多仓库读写、删除及错误返回;另覆盖撤销、取消和等待 transport 关闭 | +| [直连提供器测试](../../../src/main/agent/model-tool-provider.obsidian.test.ts) | 通过实际服务执行全部 19 个工具并核验文件,包含笔记移动、二进制附件移动和删除;覆盖 Ask、失效令牌、原生错误及关闭清理 | +| [index.ts](../../../src/main/index.ts) 与[ipc.ts](../../../src/main/ipc.ts) | 正式启动实例化、依赖注入和 IPC 注册;范围保存、开关及分配提交在持久化前撤销旧 Obsidian 请求授权 | +| [package-mcpvault.ts](../../../src/main/obsidian/package-mcpvault.ts) 与[构建配置](../../../electron.vite.config.ts) | 构建期递归复制运行依赖,并固定上游版本 | + +## 验证记录 + +| 验证项 | 2026-09-26 结果及范围 | +| --- | --- | +| 聚焦生产组件自动化 | 本轮 7 个文件、132 项测试通过,包含 Continue 适配器;另运行 IPC 的 Obsidian 用例,5 项通过、151 项跳过 | +| `npm run build` | 此前主实施方反馈通过;本轮未重跑 | +| `npm run typecheck` | 首次运行报原生终端依赖未列入项目的 TS6307;并行改动后复跑,该错误已消失,当前阻塞为 `native-terminal-client.test.ts:33` 的 TS2352 类型转换错误。未修改这部分并行开发代码 | +| `npm run lint` | 本轮通过;最终测试文件改动另行定向检查 | +| `npm test` | 本轮在 200 秒超时;结束前出现数据库迁移、Magic Notes 等失败。全量套件没有通过结论;Continue 适配器定向复跑通过 | +| Electron 托管 Node 冒烟 | 将服务测试改为真实 Electron 可执行文件,以 `ELECTRON_RUN_AS_NODE=1` 启动复制后的包,10 项通过。Windows 子进程 PATH 仅保留测试 shim 与 System32;覆盖发现、读写、局部修改、属性、回收站和取消清理。没有隔离网络,物理断网安装包验收仍待完成 | +| 安装后完整 Electron UI 与跨平台验收 | 待完成;组件自动化使用替代 Electron 传输,不能覆盖原生窗口及安装环境 | + +2026-09-26 本次文档修订新增 FR-10、LR-8、US-12,保留原有 ID。交付检查结果: + +| 文档检查 | 结果 | +| --- | --- | +| 相对链接及锚点 | 6 份功能文档的 53 个链接、4 份跨功能文档新增的 5 个引用通过 | +| 稳定 ID 与工具清单 | FR-1~FR-10、US-1~US-12、LR-1~LR-8、TS-1~TS-4 唯一声明通过;18 个上游工具和 19 个共享工具映射一致 | +| deai-writing 审校 | 功能文档 0 个阻断项、11 个复核项,逐项核对后保留状态和能力边界限定;跨功能新增段落单独扫描,0 个阻断项、0 个复核项 | +| 差异与空白 | `git diff --check` 通过;临时脚本另检查未跟踪的功能文档,无行尾空白或旧网关接入状态 | + +本轮修改服务测试和验证记录;测试使用独立临时仓库,未修改用户笔记或应用设置。 + +最终定向 ESLint 和 `git diff --check` 通过。README 与本进度文档经 deai-writing 扫描,0 个阻断项、3 个复核项;复核项均描述验收范围或草稿状态,保留事实限定。 + +聚焦测试复现入口如下;这些命令用于定位相关用例,不是全量通过证明: + +```powershell +npx vitest run src/shared/obsidian-tools.test.ts src/main/obsidian/obsidian-service.test.ts src/main/agent/knowledge-mcp-gateway.obsidian.test.ts src/main/agent/model-tool-provider.obsidian.test.ts src/main/capabilities/capability-service.test.ts src/renderer/src/McpSettingsSection.test.tsx +npx vitest run src/main/ipc.test.ts -t Obsidian +``` + +剩余验收以本文顶部阶段表为准,按[验证矩阵](./technical-design.md#验证与实施顺序)补充物理断网、安装后及其他目标系统证据。配置变更触发取消与销毁等待的具体语义以[功能逻辑](./logic-design.md#授权撤销与清理)为准。 diff --git a/docs/features/obsidian/technical-design.md b/docs/features/obsidian/technical-design.md new file mode 100644 index 00000000..5877bcea --- /dev/null +++ b/docs/features/obsidian/technical-design.md @@ -0,0 +1,103 @@ +# 技术设计 + +本文负责实现架构及 UI 设计,落实 [FR-1~FR-10](./prd.md) 和 [LR-1~LR-8](./logic-design.md)。实现与验证状态统一在[进度](./progress.md)记录。 + +## 模块边界 + +| 层 | 职责与源码 | +| --- | --- | +| Renderer | [McpSettingsSection.tsx](../../../src/renderer/src/McpSettingsSection.tsx) 编辑草稿、显示已保存范围和测试结果;不直接访问文件系统或启动 Node | +| Shared / Preload | [capability-contracts.ts](../../../src/shared/capability-contracts.ts)、[contracts.ts](../../../src/shared/contracts.ts)、[ipc-channels.ts](../../../src/shared/ipc-channels.ts) 和 [preload/index.ts](../../../src/preload/index.ts) 定义窄接口 | +| 配置服务 | [capability-service.ts](../../../src/main/capabilities/capability-service.ts) 保存范围,沿用内置 MCP 的启用和分配状态 | +| Main 服务 | [obsidian-service.ts](../../../src/main/obsidian/obsidian-service.ts) 发现仓库、连接上游、列工具和调用工具 | +| 会话网关 | [knowledge-mcp-gateway.ts](../../../src/main/agent/knowledge-mcp-gateway.ts) 提供 HTTP MCP、仓库发现、目标路由、请求授权撤销及活动调用清理 | +| 共享目录与直连 | [obsidian-tools.ts](../../../src/shared/obsidian-tools.ts) 定义 19 个工具;[model-tool-provider.ts](../../../src/main/agent/model-tool-provider.ts) 发现并调用同一目录,保留上游错误详情 | +| 构建 | [package-mcpvault.ts](../../../src/main/obsidian/package-mcpvault.ts) 复制依赖树,[electron.vite.config.ts](../../../electron.vite.config.ts) 调用打包函数,[package.json](../../../package.json) 配置版本及解包资源 | + +正式链路为设置页面经 Preload、受信任的 Main IPC 处理器到配置服务或 ObsidianService;会话经既有能力入口到 ObsidianService,再通过 stdio 到 MCPVault。[index.ts](../../../src/main/index.ts) 创建服务并注入工具启动环境、网关及 IPC;[ipc.ts](../../../src/main/ipc.ts) 已注册设置、测试和目录选择处理器,并在会话入口按保存设置及工作模式签发请求授权。 + +## 配置与 IPC + +`obsidianSettingsSchema` 是严格对象,仅有 `vaultPath`:字符串,去除首尾空白,最大 4096 字符,默认 `''`。不接收 `readOnly`。持久化范围为桌面全局,沿用 CapabilityService 的存储,不增加数据库或配置副本。 + +| 接口 | 输入与结果 | 副作用 | +| --- | --- | --- | +| `updateObsidianSettings` | `{ vaultPath }` → `CapabilitySnapshot` | 先撤销旧 Obsidian 请求授权,再保存范围;启用与分配值不变 | +| `testObsidianConnection` | 草稿 `{ vaultPath }` → `{ vaults, toolCount }` | 临时连接,结束即清理,不保存设置 | +| `selectObsidianVault` | 无输入 → 目录字符串或 `null` | Main 打开系统目录选择器,取消返回 `null` | + +`vaults` 的元素为 `{ id, name, path }`。Main 处理器验证调用来源和共享 schema。范围保存只验证设置形状;目录存在性由发现或测试校验,保存成功不能显示为连接成功。已有配置缺少新字段时补默认值,其他能力设置保持原值。 + +设置更新、内置 MCP 开关及分配处理器在参数校验后调用 `knowledgeGateway.revokeObsidianCapabilities()`;后两者只在 `serverId === 'obsidian'` 时调用。撤销发生在持久化前,之后通过既有 `refreshCapabilities` 刷新配置;失败语义由 [LR-8](./logic-design.md#授权撤销与清理)定义。 + +## 仓库发现与路由 + +注册文件位置如下,优先使用相应环境变量: + +| 系统 | 注册文件 | +| --- | --- | +| Windows | `%APPDATA%/obsidian/obsidian.json`,缺少变量时使用用户目录下 `AppData/Roaming` | +| macOS | `~/Library/Application Support/obsidian/obsidian.json` | +| Linux | `$XDG_CONFIG_HOME/obsidian/obsidian.json`,缺少变量时使用 `~/.config` | + +注册条目的键直接用作 ID。指定文件夹经绝对路径规范化后生成 SHA-256 ID,Windows 对哈希输入转小写;这是路径身份,不是文件完整性校验或跨机器永久身份。目录移动或注册 ID 变化后,调用方须重新发现仓库。 + +`callTool` 接收 `{ vaultId?, name, arguments }`,按 LR-3 验证目标后转发。共享工具 `obsidian_list_vaults` 返回仓库列表,其余工具名称为 `obsidian_` 加上游名称,输入额外包含 `vaultId`。网关校验共享 schema,提取 `vaultId`,移除名称前缀后调用上游;其余参数原样保留。输入默认值保持可选,frontmatter 键不受外层严格对象限制。[共享契约测试](../../../src/shared/obsidian-tools.test.ts)逐项比对上游字段、必填项、默认值及约束。 + +## 上游工具契约 + +名称集合依据本地安装的 `@bitbonsai/mcpvault@0.16.0` 中 `dist/src/createServer.js` 核对。HTTP 网关和直连工具提供器共享 19 个工具:`obsidian_list_vaults` 加下表 18 个工具的带前缀映射。设置连接测试返回的 `toolCount` 为上游名称去重数,正常为 18,不包含仓库发现工具。 + +| 上游工具 | 能力 | +| --- | --- | +| `read_note` | 读取笔记 | +| `write_note` | 写入笔记 | +| `patch_note` | 局部替换笔记内容 | +| `list_directory` | 列出目录 | +| `delete_note` | 按上游删除参数处理笔记 | +| `search_notes` | 搜索笔记 | +| `move_note` | 移动或重命名笔记 | +| `move_file` | 移动文件,包括上游支持的附件文件 | +| `read_multiple_notes` | 批量读取笔记 | +| `update_frontmatter` | 更新 frontmatter | +| `get_notes_info` | 获取笔记信息 | +| `get_frontmatter` | 获取 frontmatter | +| `manage_tags` | 管理标签 | +| `get_vault_stats` | 获取仓库统计 | +| `list_all_tags` | 列出标签 | +| `wiki_link` | 处理 wiki 链接 | +| `get_note_outline` | 获取笔记大纲 | +| `read_note_lines` | 按行读取笔记 | + +服务分页读取上游工具,不设置功能白名单。读写属性供应用既有工作模式使用,不能据此增加 Obsidian 专用过滤。保留上游的错误结果与参数规则,例如删除参数 `confirmPath`;不把参数要求改造成额外确认弹窗。 + +## 离线打包与生命周期 + +构建固定 npm 版本为 `0.16.0`,复制包及递归运行依赖,保留许可证、模块相对资源和 `trash` 的原生辅助程序。资源位于 `out/main/obsidian-mcpvault/node_modules`,生产安装包将其放在 `app.asar.unpacked`。缺包或版本不符必须明确失败,不在运行时执行 `npx` 或回退到在线安装。 + +服务将 `app.asar` 路径映射到解包目录,检查 `@bitbonsai/mcpvault/dist/server.js` 存在,使用现有 `LaunchEnvironmentProvider` 注入命令环境后运行 `node `,工作目录为目标仓库。托管 Node 的准备和用户自定义解释器规则由[工具执行环境技术设计](../local-tool-environment/technical-design.md)负责。Obsidian 不新增下载源;已有工具下载源不控制随包交付的 MCPVault。 + +每次列举或调用创建 stdio 连接,在 `finally` 中关闭 transport 和 client,初始化失败也清理。`callTool` 传递 `AbortSignal`,启动前已取消时不启动进程。连接测试逐仓库列工具,不调用写入工具;它没有独立取消 IPC。网关沿用现有 MCP 结果大小约束,保留上游原生内容和错误详情,超时沿用 SDK 与既有会话路径。 + +`callObsidianTool` 合并请求、授权撤销及单次调用的取消信号,并在结果返回前再次检查取消。`revokeObsidianCapabilities` 对含 Obsidian 的授权调用既有 `revoke`,移除令牌并中止其 controller;混合授权整体失效。网关以 `activeObsidianCalls` 跟踪调用 Promise,`dispose()` 先撤销授权,再等待这些 Promise 完成,随后关闭下游会话和 HTTP 服务。由于服务 Promise 包含 `finally` 中的 transport 关闭,等待范围包含真实子进程清理,也包含先前撤销后尚未关闭的调用。 + +## UI 设计 + +此章节独立承担功能 UI 设计职责,服从[统一界面设计系统](../../../UI-DESIGN.md)。入口为“能力与工具 → MCP → 内置 MCP”的 Obsidian 卡片,复用 `PageTabs`、卡片、表单、按钮、Switch 及 Runtime 分配 Checkbox。 + +卡片顺序为名称及状态、启用开关、能力说明、仓库范围、可选路径输入、已保存范围、选择/测试/保存操作、关联反馈、Runtime 分配和可展开工具列表。主按钮为“保存”,测试与选择文件夹为次按钮。范围选项为“所有本机仓库”和“指定文件夹”;指定模式才显示路径输入框。页面明确区分草稿与“已保存范围”。 + +关闭状态仍允许选择、测试和保存。快照尚未载入或操作忙碌时禁用相关提交,测试按钮显示进度。修改范围或路径清除旧结果;目录选择取消保留原值。连接结果在卡片内显示仓库名称、路径和工具数,作为可复查的诊断内容;字段错误及可立即重试的测试错误就近显示,保留输入。普通保存提示如需增加,使用应用通知,不重复显示同一事件。 + +启用控件使用 `role="switch"`;范围、路径和按钮具备可访问名称,路径错误使用 `aria-describedby`,错误用 `role="alert"`,测试结果用 `role="status"`。工具展开按钮提供 `aria-expanded` 和 `aria-controls`。沿用原生键盘行为和共享焦点样式。Obsidian 的多字段诊断表单采用纵向布局,操作按钮换行,结果中的长路径允许折行;浅色和深色主题均复用语义令牌。Windows 760 像素宽窗口的实际测量及原生输入结果见[进度](./progress.md)。 + +## 验证与实施顺序 + +| 阶段 | 验证内容 | 追溯 | +| --- | --- | --- | +| TS-1 配置与服务 | 缺省字段、持久化独立性、各平台注册路径、ID 路由、真实上游读写、取消清理 | LR-1~LR-3、LR-5~LR-7 | +| TS-2 设置 UI | 草稿测试不保存不启用、切换范围、取消选择、失败保留输入、键盘与窄窗口 | US-1~US-3、US-7、US-8 | +| TS-3 网关与 IPC | 正式 Main 处理器、19 个共享工具、分配、Ask/Execute、配置提交撤销旧授权、在途取消与销毁等待;核对 DeepSeek Harness 和远程执行不可用边界 | LR-3、LR-4、LR-7、LR-8;US-4、US-5、US-9~US-12 | +| TS-4 安装包与端到端 | 在实际支持系统上断网且无系统 Node 测试;Obsidian 关闭;许可证及原生资源完整;从设置到真实会话读写 | FR-5、FR-6;US-6 | + +当前分配目标为 `model`、`opencode`、`continue`;[DeepSeek Harness](../../../src/main/agent/deepseek-harness-runtime.ts)和[远程 ACP Runtime](../../../src/main/agent/acp-remote-runtime.ts)的 `supportsScopedDataTools` 为 `false`,沿用既有不可用边界。已通过的生产组件自动化包含设置组件到注册 IPC、HTTP 网关,以及直连工具提供器全部 19 个工具的实际执行。真实聊天会话、Electron 托管 Node、Windows 解包应用及网络限制的不同验证范围见[进度](./progress.md)。 diff --git a/docs/features/obsidian/user-stories.md b/docs/features/obsidian/user-stories.md new file mode 100644 index 00000000..d4a6e510 --- /dev/null +++ b/docs/features/obsidian/user-stories.md @@ -0,0 +1,20 @@ +# 用户故事 + +本期角色为使用本机笔记的桌面用户,以及替用户调用工具的会话助手。需求定义见[PRD](./prd.md)。下表描述验收条件,不表示场景已经通过。 + +| ID | 场景与需求 | Given(前提) | When(动作) | Then(预期结果) | +| --- | --- | --- | --- | --- | +| US-1 | 首次检查,FR-1、FR-2、FR-7 | 本机有已注册仓库,功能尚未启用 | 用户进入 MCP 设置并测试默认范围 | 开关保持关闭;返回注册仓库列表和工具数;未保存配置,也未改动笔记 | +| US-2 | 指定草稿范围,FR-2、FR-6、FR-7 | 用户选择一个未注册文件夹,尚未保存 | 用户测试该草稿 | 仅探测此文件夹;已保存范围及启用状态不变 | +| US-3 | 保存与恢复默认,FR-1、FR-2 | 用户已测试指定文件夹 | 保存后重新进入设置,再切换为全部仓库并保存 | 保存结果可重新加载;恢复默认时保存空 `vaultPath`;两次保存均不自动启用 | +| US-4 | 单仓库日常操作,FR-4、FR-9 | 功能已启用并分配给当前会话,范围只有一个仓库 | 助手在允许写入的既有工作模式下读取、修改并再次读取笔记 | 写回当前唯一仓库,结果可从文件核验;无需额外 Obsidian 审批 | +| US-5 | 多仓库同名文件,FR-3 | 有两个仓库,都包含 `daily.md` | 助手先取得仓库 ID,再指定其中一个修改;另行尝试省略 ID 或传入无效 ID | 明确 ID 的请求仅作用于对应仓库;另两次调用返回错误,两个仓库均不因失败请求改变 | +| US-6 | 离线与关闭 Obsidian,FR-5、FR-6 | 安装包已有依赖,Obsidian 未运行,系统未安装 Node,网络断开 | 使用内置服务测试、读取和写入测试仓库 | 本地操作通过现有托管 Node 完成,不请求 npm,也不要求插件;模型联网另行验证 | +| US-7 | 范围失败恢复,FR-8 | 注册文件缺失或含失效路径,或者草稿路径不可访问 | 用户测试后改为有效文件夹并重试 | 显示具体原因,保留输入;重试使用新草稿,不偷偷跳过坏仓库或切换目标 | +| US-8 | 取消与失败保留,FR-8 | 用户正在编辑草稿或执行会话工具 | 取消文件夹选择、遇到保存失败,或取消会话工具 | 选择取消和保存失败均保留草稿;工具取消关闭对应进程,不报告成功,也不宣称回滚已发生的写入 | +| US-9 | 既有权限规则,FR-4、FR-9 | 功能启用且已分配,用户分别使用 Ask 和 Execute | 助手请求写入 | Ask 按应用现有规则保持只读;Execute 沿用正常执行规则,不增加 Obsidian 专用开关、过滤或审批 | +| US-10 | 旧配置与分配,FR-1、FR-9 | 旧配置没有 Obsidian 字段,已有其他 MCP 配置 | 加载设置并修改 Obsidian 分配 | Obsidian 默认关闭,其他配置保留;修改分配不改写仓库范围;未分配的会话不可调用 | +| US-11 | 上游能力完整,FR-4 | 在允许完整调用的工作模式下启用接入 | 获取工具集合并对照 MCPVault 0.16.0 | 18 个上游工具均有可调用映射,参数能力保留;仓库发现辅助工具不计入这 18 个 | +| US-12 | 设置变更与旧调用,FR-10 | 有含 Obsidian 的旧请求授权和正在执行的工具 | 提交保存范围、切换开关或修改分配,随后关闭网关 | 旧授权失效,在途调用收到取消;网关等待工具关闭。保存失败也不恢复旧授权;后续请求按实际保存状态重新取得授权 | + +跨平台注册路径和安装包测试分别取证。通过模拟平台字符串测试注册路径,不等于在对应操作系统上完成安装包验收。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c717398e..f6c4fad6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import react from '@vitejs/plugin-react' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import type { Plugin } from 'vite' +import { packageObsidianMcpVault } from './src/main/obsidian/package-mcpvault' type ProjectPackage = { dependencies?: Record @@ -106,7 +107,16 @@ export default defineConfig({ 'zod' ] }), - deepSeekHarnessBundleManifestPlugin() + deepSeekHarnessBundleManifestPlugin(), + { + name: 'obsidian-mcpvault-package', + async writeBundle(options) { + await packageObsidianMcpVault( + resolve('.'), + resolve(options.dir ?? 'out/main', 'obsidian-mcpvault') + ) + } + } ], build: { rollupOptions: { diff --git a/package-lock.json b/package-lock.json index 53afbf22..d7575857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ }, "devDependencies": { "@antv/g6": "^5.1.1", + "@bitbonsai/mcpvault": "0.16.0", "@continuedev/cli": "1.5.47", "@eslint/js": "^10.0.1", "@fontsource-variable/inter": "^5.3.0", @@ -1225,6 +1226,25 @@ "node": ">=6.9.0" } }, + "node_modules/@bitbonsai/mcpvault": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@bitbonsai/mcpvault/-/mcpvault-0.16.0.tgz", + "integrity": "sha512-HTGAi5eA9UM94Op9bfujz7UoCs51NcYQHU4Os3oXf5UX0QVrN3d1kQguL8erKFCgg7IeqH/PnbW23qRYmskzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "gray-matter": "^4.0.3", + "trash": "^10.1.1", + "yaml": "^2.9.0" + }, + "bin": { + "mcpvault": "dist/server.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -3892,6 +3912,19 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.1.0.tgz", + "integrity": "sha512-YdFj5gMRHr0gYNAhTbQDgjLHiEJdIAUYxomhKseUNye7ZGlISQmUoLh2sveFFCiQ7j2YMffT1kgSND0FSfIFjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -3932,6 +3965,20 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.1.0.tgz", + "integrity": "sha512-FdvwZU5N03O77Suh35JqjAqq5R1ZTEPFtwu6N4BgBq28qrQtrCnpbLL5U2pwYCuE194jAw0xnlOVmIqhfoYuUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.1.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -4287,6 +4334,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@opencode-ai/plugin": { "version": "1.18.29", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.29.tgz", @@ -4854,6 +4939,19 @@ "selderee": "~0.12.0" } }, + "node_modules/@sindresorhus/df": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-3.1.1.tgz", + "integrity": "sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4867,6 +4965,19 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@smithy/core": { "version": "3.33.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", @@ -4992,6 +5103,16 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stroncium/procfs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@stroncium/procfs/-/procfs-1.2.1.tgz", + "integrity": "sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": ">=8" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -6913,6 +7034,19 @@ "node": "20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -7287,6 +7421,19 @@ "dev": true, "license": "MIT" }, + "node_modules/chunkify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chunkify/-/chunkify-5.0.0.tgz", + "integrity": "sha512-G8dj/3/Gm+1yL4oWSdwIxihZWFlgC4V2zYtIApacI0iPIRKBHlBGOGAiDUBZgrj4H8MBA8g8fPFwnJrWF3wl7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -9406,6 +9553,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -9508,6 +9669,27 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-2.1.0.tgz", + "integrity": "sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^3.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": "^8.12.0 || >=9.7.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -9628,6 +9810,19 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fabric": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.4.0.tgz", @@ -9848,6 +10043,36 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9889,6 +10114,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -9996,6 +10231,19 @@ "node": ">=10" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -10437,6 +10685,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/google-auth-library": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", @@ -10518,6 +10797,53 @@ "lodash": "^4.17.15" } }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gray-matter/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", @@ -11229,6 +11555,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11271,6 +11623,48 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -11296,6 +11690,35 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -11628,6 +12051,16 @@ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/koffi": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.4.tgz", @@ -12183,6 +12616,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/mermaid": { "version": "11.16.1", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", @@ -12794,6 +13244,33 @@ ], "license": "MIT" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -12828,6 +13305,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -12961,6 +13448,44 @@ "ml-array-rescale": "^2.0.0" } }, + "node_modules/mount-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mount-point/-/mount-point-3.0.0.tgz", + "integrity": "sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^1.0.1", + "pify": "^2.3.0", + "pinkie-promise": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mount-point/node_modules/@sindresorhus/df": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-1.0.1.tgz", + "integrity": "sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/move-file": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/move-file/-/move-file-4.1.0.tgz", + "integrity": "sha512-YE06K9XLIvMlqSfoZTl32qvbZLPgL70Za41wS8pEhsSOhy71xz2fn8J07nuz/LEEtPSuUzLUFGAJSx499eKDSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13455,6 +13980,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/npm/node_modules/@gar/promise-retry": { "version": "1.0.3", "inBundle": true, @@ -15138,6 +15676,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/onnxruntime-common": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.2.tgz", @@ -15393,6 +15947,16 @@ "node": ">= 0.8.0" } }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -15403,6 +15967,16 @@ "node": ">=8" } }, + "node_modules/p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -15435,6 +16009,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -15604,6 +16191,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -15674,6 +16274,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -15855,6 +16488,19 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ppu-ocv": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ppu-ocv/-/ppu-ocv-4.0.0.tgz", @@ -16324,6 +16970,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -16743,6 +17410,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -16873,6 +17551,30 @@ "license": "MIT", "optional": true }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -16930,6 +17632,20 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/selderee": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", @@ -17255,6 +17971,19 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17414,6 +18143,26 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -17729,6 +18478,19 @@ "tmp": "^0.2.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -17773,6 +18535,30 @@ "node": ">=20" } }, + "node_modules/trash": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/trash/-/trash-10.1.1.tgz", + "integrity": "sha512-L/mu8sfblMwaS+exj1MxpmihlIRwVQyB6ieKuTTmBJG0lXWBPfx3pMGQG8i3NT/S8vvNZrflDUOp+j0o7Cnxzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stroncium/procfs": "^1.2.1", + "chunkify": "^5.0.0", + "globby": "^14.1.0", + "is-path-inside": "^4.0.0", + "move-file": "^4.1.0", + "p-map": "^7.0.3", + "powershell-utils": "^0.2.0", + "wsl-utils": "^0.4.0", + "xdg-trashdir": "^3.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -18224,6 +19010,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -19171,6 +19970,62 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.4.0.tgz", + "integrity": "sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/xdg-trashdir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xdg-trashdir/-/xdg-trashdir-3.1.0.tgz", + "integrity": "sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^3.1.1", + "mount-point": "^3.0.0", + "user-home": "^2.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 5ebed25c..f45cd889 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "beforePack": "build/runtime-hooks.cjs", "asar": true, "asarUnpack": [ + "out/main/obsidian-mcpvault/**/*", "out/main/package.json", "out/main/deepseek-harness-*", "out/main/embedding-inference-bootstrap.js", @@ -366,6 +367,7 @@ }, "devDependencies": { "@antv/g6": "^5.1.1", + "@bitbonsai/mcpvault": "0.16.0", "@continuedev/cli": "1.5.47", "@eslint/js": "^10.0.1", "@fontsource-variable/inter": "^5.3.0", diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index e4906d52..1aa74506 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -12,6 +12,7 @@ import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { scopedReadToolNames } from '../../shared/scoped-data-tools' import { ContinueHostAdapter, inspectContinueNativeConfiguration, @@ -777,22 +778,7 @@ describe('ContinueHostAdapter', () => { expect(args).toEqual([ '--config', expect.stringContaining('knowledge-config-'), - '--allow', - 'knowledge_list', - '--allow', - 'knowledge_search', - '--allow', - 'note_list', - '--allow', - 'note_get', - '--allow', - 'note_search', - '--allow', - 'goodbuddy_config_capabilities', - '--allow', - 'goodbuddy_config_get', - '--allow', - 'goodbuddy_config_plan', + ...scopedReadToolNames.flatMap((name) => ['--allow', name]), '--exclude', '*', 'serve', diff --git a/src/main/agent/knowledge-mcp-gateway.obsidian.test.ts b/src/main/agent/knowledge-mcp-gateway.obsidian.test.ts new file mode 100644 index 00000000..fe024ef1 --- /dev/null +++ b/src/main/agent/knowledge-mcp-gateway.obsidian.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment node +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { CallToolResultSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { obsidianReadToolNames, obsidianScopedDataTools, obsidianWriteToolNames } from '../../shared/obsidian-tools' +import type { KnowledgeService } from '../knowledge/knowledge-service' +import { ObsidianService, type ObsidianSettings } from '../obsidian/obsidian-service' +import { packageObsidianMcpVault } from '../obsidian/package-mcpvault' +import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' + +let root: string +let vaultPath: string +let service: ObsidianService +const gateways: KnowledgeMcpGateway[] = [] +const clients: Client[] = [] +const provider = vi.fn<() => NodeJS.ProcessEnv>() + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-gateway-')) + vaultPath = join(root, 'First Vault') + const second = join(root, 'Second Vault') + const config = join(root, 'config') + const bin = join(root, 'managed-bin') + await Promise.all([vaultPath, second, bin, join(config, 'obsidian')].map((path) => mkdir(path, { recursive: true }))) + await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: { + first: { path: vaultPath }, second: { path: second } + } })) + await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'), + process.platform === 'win32' + ? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n` + : `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`, + { mode: 0o755 }) + provider.mockImplementation(() => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` })) + const appPath = join(root, 'app') + await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault')) + service = new ObsidianService({ appPath, launchEnvironmentProvider: provider, + platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config } }) +}, 60_000) + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose())) + vi.restoreAllMocks() +}) +afterAll(async () => { await rm(root, { recursive: true, force: true }) }) + +function grant(access: 'read' | 'write', settings: ObsidianSettings = {}, signal = new AbortController().signal) { + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service }) + gateways.push(gateway) + const token = gateway.grant('obsidian-test', [], signal, 'none', undefined, undefined, undefined, undefined, { settings, access })! + return { gateway, token } +} + +async function connect(gateway: KnowledgeMcpGateway, token: string) { + await gateway.start() + const client = new Client({ name: 'obsidian-gateway-test', version: '1.0.0' }) + clients.push(client) + await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { + requestInit: { headers: { Authorization: `Bearer ${token}` } } + })) + return client +} + +function text(result: CallToolResult) { + return result.content.map((item) => item.type === 'text' ? item.text : '').join('\n') +} + +describe('Obsidian gateway', () => { + it('uses the shared Ask classification and exposes every tool for Execute', async () => { + const read = grant('read') + const write = grant('write') + expect(read.gateway.getAvailableToolNames(read.token)).toEqual(obsidianReadToolNames) + expect(write.gateway.getAvailableToolNames(write.token)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames]) + const client = await connect(write.gateway, write.token) + const tools = (await client.listTools()).tools + expect(tools).toHaveLength(19) + expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort()) + for (const definition of obsidianScopedDataTools) { + expect(tools.find((tool) => tool.name === definition.name)?.annotations?.readOnlyHint).toBe(definition.access === 'read') + } + await expect(read.gateway.callObsidianTool(read.token, 'obsidian_write_note', { path: 'blocked.md', content: '' })).rejects.toThrow('capability is unavailable') + const absent = new KnowledgeMcpGateway({} as KnowledgeService) + gateways.push(absent) + expect(absent.grant('absent', [], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, + { settings: {}, access: 'write' })).toBeUndefined() + }) + + it('writes, reads, lists and deletes through HTTP using the real service and explicit vault targets', async () => { + const { gateway, token } = grant('write') + const client = await connect(gateway, token) + const call = async (name: string, args: Record = {}) => + CallToolResultSchema.parse(await client.callTool({ name, arguments: args }, CallToolResultSchema)) + const vaults = JSON.parse(text(await call('obsidian_list_vaults'))).vaults + expect(vaults.map((vault: { id: string }) => vault.id)).toEqual(['first', 'second']) + await expect(call('obsidian_write_note', { path: 'gateway.md', content: 'ambiguous' })).rejects.toThrow('vaultId is required') + await expect(call('obsidian_read_note', { vaultId: 'missing', path: 'gateway.md' })).rejects.toThrow('Unknown Obsidian vaultId') + for (const vaultId of ['first', 'second']) { + expect((await call('obsidian_write_note', { vaultId, path: 'gateway.md', content: `# ${vaultId}` })).isError).not.toBe(true) + expect(text(await call('obsidian_read_note', { vaultId, path: 'gateway.md' }))).toContain(`# ${vaultId}`) + expect(text(await call('obsidian_list_directory', { vaultId }))).toContain('gateway.md') + } + expect(await readFile(join(vaultPath, 'gateway.md'), 'utf8')).toContain('# first') + const failedDelete = await call('obsidian_delete_note', { vaultId: 'first', path: 'gateway.md', confirmPath: 'wrong.md' }) + expect(failedDelete.isError).toBe(true) + expect(text(failedDelete)).toContain('confirmPath') + expect(text(failedDelete)).not.toContain('"content":[') + for (const vaultId of ['first', 'second']) { + expect((await call('obsidian_delete_note', { vaultId, path: 'gateway.md', confirmPath: 'gateway.md' })).isError).not.toBe(true) + expect(text(await call('obsidian_list_directory', { vaultId }))).not.toContain('gateway.md') + } + expect(provider).toHaveBeenCalled() + }, 30_000) + + it('allows an omitted vaultId for an explicit single vault and returns native errors directly', async () => { + const { gateway, token } = grant('write', { vaultPath }) + const result = await gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'absent.md' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('absent.md') + expect(JSON.parse(text(await gateway.callObsidianTool(token, 'obsidian_list_vaults', {}))).vaults).toHaveLength(1) + }) + + it('preserves native content and enforces the existing MCP result bound in HTTP and direct calls', async () => { + const { gateway, token } = grant('read') + const native: CallToolResult = { isError: true, content: [ + { type: 'text', text: 'upstream detail' }, + { type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' } + ], structuredContent: { detail: 'upstream detail' } } + const call = vi.spyOn(service, 'callTool').mockResolvedValue(native) + const client = await connect(gateway, token) + expect(await gateway.callObsidianTool(token, 'obsidian_read_note', { vaultId: 'first', path: 'test.md' })).toEqual(native) + expect(call).toHaveBeenLastCalledWith({}, { vaultId: 'first', name: 'read_note', arguments: { path: 'test.md', prettyPrint: false } }, expect.any(AbortSignal)) + expect(await client.callTool({ name: 'obsidian_read_note', arguments: { path: 'test.md' } }, CallToolResultSchema)).toEqual(native) + call.mockResolvedValue({ content: [{ type: 'text', text: 'x'.repeat(256 * 1024) }] }) + await expect(gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'large.md' })).rejects.toThrow('256KB') + await expect(client.callTool({ name: 'obsidian_read_note', arguments: { path: 'large.md' } })).rejects.toThrow('256KB') + }) + + it('revokes all Obsidian grants while retaining unrelated capabilities and allowing new grants', () => { + const { gateway, token } = grant('read') + const signal = new AbortController().signal + const writeToken = gateway.grant('write', [], signal, 'none', undefined, undefined, undefined, undefined, + { settings: { vaultPath }, access: 'write' })! + const mixedToken = gateway.grant('mixed', ['library'], signal, 'none', undefined, undefined, undefined, undefined, + { settings: {}, access: 'write' })! + const knowledgeToken = gateway.grant('knowledge', ['library'], signal)! + gateway.revokeObsidianCapabilities() + gateway.revokeObsidianCapabilities() + for (const revoked of [token, writeToken, mixedToken]) { + expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired') + } + expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search']) + const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined, + { settings: { vaultPath }, access: 'write' })! + expect(gateway.getAvailableToolNames(replacement)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames]) + }) + + it.each([false, true])('awaits transport close during disposal, including previously revoked calls (%s)', async (revokeFirst) => { + const { gateway, token } = grant('read', { vaultPath }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + let markClosing!: () => void + const closing = new Promise((resolve) => { markClosing = resolve }) + let releaseClose!: () => void + const closeGate = new Promise((resolve) => { releaseClose = resolve }) + let closed = false + const close = StdioClientTransport.prototype.close + vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) { + markClosing() + await closeGate + await close.call(this) + closed = true + }) + const send = StdioClientTransport.prototype.send + vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) { + const result = send.call(this, message) + if ('method' in message && message.method === 'tools/call') markStarted() + return result + }) + const pending = gateway.callObsidianTool(token, 'obsidian_list_directory', {}) + .then(() => undefined, (error: unknown) => error) + await Promise.race([started, pending.then(() => { throw new Error('Call ended before upstream dispatch') })]) + if (revokeFirst) gateway.revokeObsidianCapabilities() + let disposed = false + const disposal = gateway.dispose().then(() => { disposed = true }) + try { + await closing + await new Promise((resolve) => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(closed).toBe(false) + } finally { + releaseClose() + await disposal + await pending + } + expect(closed).toBe(true) + expect(await pending).toBeInstanceOf(Error) + }) + + it.each(['revoke', 'settings', 'request', 'call', 'dispose'] as const)('cancels and closes the actual upstream child on %s', async (cancellation) => { + const controller = new AbortController() + const callController = new AbortController() + const { gateway, token } = grant('read', { vaultPath }, controller.signal) + const transports = new Set() + const send = StdioClientTransport.prototype.send + vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) { + transports.add(this) + const result = send.call(this, message) + if ('method' in message && message.method === 'tools/call') { + if (cancellation === 'revoke') gateway.revoke(token) + else if (cancellation === 'settings') gateway.revokeObsidianCapabilities() + else if (cancellation === 'request') controller.abort(new Error('Request cancelled')) + else if (cancellation === 'call') callController.abort(new Error('Call cancelled')) + else void gateway.dispose() + } + return result + }) + await expect(gateway.callObsidianTool(token, 'obsidian_list_directory', {}, callController.signal)).rejects.toThrow(/revoked|cancelled/) + expect(transports.size).toBe(1) + for (const transport of transports) expect(transport.pid).toBeNull() + }) +}) diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 8724c54b..77fbb9e4 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -40,6 +40,8 @@ import { type ScopedDataToolName } from '../../shared/scoped-data-tools' import type { KnowledgeService } from '../knowledge/knowledge-service' +import type { ObsidianService, ObsidianSettings } from '../obsidian/obsidian-service' +import { obsidianReadToolNames, obsidianWriteToolNames } from '../../shared/obsidian-tools' import type { MagicNoteDetail, MagicNoteEntry, @@ -199,6 +201,7 @@ type Capability = { browserConversationId?: string browserTabId?: BrowserTabId browserUsageLease?: BrowserTabUsageLease + obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } expiresAt: number signal: AbortSignal brokerController: AbortController @@ -244,6 +247,7 @@ export type KnowledgeMcpGatewayOptions = { now?: () => number magicNotesDatabase?: MagicNotesDatabase configService?: GoodBuddyConfigService + obsidianService?: ObsidianService browserService?: BrowserToolService & { createTab: BrowserService['createTab'] listTabs(conversationId: string): BrowserTabSummary[] @@ -367,11 +371,13 @@ export class KnowledgeMcpGateway { >() private readonly downstreamMcpCleanups = new Set>() private readonly customMcpCleanups = new Set>() + private readonly activeObsidianCalls = new Set>() private readonly now: () => number private readonly capabilityTtlMs: number private readonly maximumBodyBytes: number private readonly magicNotesDatabase?: MagicNotesDatabase private readonly configService?: GoodBuddyConfigService + private readonly obsidianService?: ObsidianService private readonly browserService?: BrowserToolService & { createTab: BrowserService['createTab'] listTabs(conversationId: string): BrowserTabSummary[] @@ -403,6 +409,7 @@ export class KnowledgeMcpGateway { this.now = options.now ?? Date.now this.magicNotesDatabase = options.magicNotesDatabase this.configService = options.configService + this.obsidianService = options.obsidianService this.browserService = options.browserService this.launchEnvironmentProvider = options.launchEnvironmentProvider } @@ -458,7 +465,8 @@ export class KnowledgeMcpGateway { }, browserConversationId?: string, browserTabId?: BrowserTabId, - browserUsageLease?: BrowserTabUsageLease + browserUsageLease?: BrowserTabUsageLease, + obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } ): string | undefined { const effectiveMagicNotesAccess = this.magicNotesDatabase ? magicNotesAccess @@ -466,6 +474,7 @@ export class KnowledgeMcpGateway { const effectiveConfigAccess = this.configService ? config?.access ?? 'none' : 'none' + const effectiveObsidian = this.obsidianService ? obsidian : undefined const effectiveBrowserConversationId = this.browserService ? browserConversationId : undefined @@ -506,7 +515,8 @@ export class KnowledgeMcpGateway { authorizedLibraryIds.length === 0 && effectiveMagicNotesAccess === 'none' && effectiveConfigAccess === 'none' && - !effectiveBrowserConversationId + !effectiveBrowserConversationId && + !effectiveObsidian ) { return undefined } @@ -518,6 +528,7 @@ export class KnowledgeMcpGateway { libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]), magicNotesAccess: effectiveMagicNotesAccess, configAccess: effectiveConfigAccess, + obsidian: effectiveObsidian, browserConversationId: effectiveBrowserConversationId, browserTabId: effectiveBrowserTabId, browserUsageLease: effectiveBrowserUsageLease, @@ -640,6 +651,14 @@ export class KnowledgeMcpGateway { }) } + revokeObsidianCapabilities(): void { + for (const [token, capability] of this.capabilities) { + if (capability.obsidian) { + this.revoke(token) + } + } + } + private closeDownstreamMcpSession( session: DownstreamMcpSession ): Promise { @@ -796,6 +815,8 @@ export class KnowledgeMcpGateway { ...(capability.configAccess === 'write' ? goodbuddyConfigWriteToolNames : []), + ...(capability.obsidian ? obsidianReadToolNames : []), + ...(capability.obsidian?.access === 'write' ? obsidianWriteToolNames : []), ...(capability.browserConversationId && capability.browserTabId ? browserToolNames : []) @@ -1228,6 +1249,50 @@ export class KnowledgeMcpGateway { ) } + async callObsidianTool( + token: string, + name: Extract, + input: unknown, + signal?: AbortSignal + ): Promise { + const capability = this.getCapability(token) + const definition = scopedDataToolByName.get(name) + if ( + !this.obsidianService || + !capability.obsidian || + !definition || + !name.startsWith('obsidian_') || + (definition.access === 'write' && capability.obsidian.access !== 'write') + ) { + throw new Error('Obsidian capability is unavailable') + } + const effectiveSignal = AbortSignal.any([ + capability.signal, + capability.brokerController.signal, + ...(signal ? [signal] : []) + ]) + effectiveSignal.throwIfAborted() + const parsed = definition.inputSchema.parse(input) as Record + const { vaultId, ...argumentsValue } = parsed + const pending = name === 'obsidian_list_vaults' + ? this.obsidianService.listVaults(capability.obsidian.settings).then((vaults) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ vaults }) }] + })) + : this.obsidianService.callTool(capability.obsidian.settings, { + vaultId: vaultId as string | undefined, + name: name.slice('obsidian_'.length), + arguments: argumentsValue + }, effectiveSignal) + this.activeObsidianCalls.add(pending) + try { + const result = await pending + effectiveSignal.throwIfAborted() + return ensureBoundedCustomMcpResult(result) + } finally { + this.activeObsidianCalls.delete(pending) + } + } + private requireConfig( token: string, requiredAccess: Exclude @@ -1513,6 +1578,26 @@ export class KnowledgeMcpGateway { case 'goodbuddy_config_plan': case 'goodbuddy_config_apply': return this.callGoodBuddyConfigTool(token, name, input) + case 'obsidian_list_vaults': + case 'obsidian_read_note': + case 'obsidian_write_note': + case 'obsidian_patch_note': + case 'obsidian_list_directory': + case 'obsidian_delete_note': + case 'obsidian_search_notes': + case 'obsidian_move_note': + case 'obsidian_move_file': + case 'obsidian_read_multiple_notes': + case 'obsidian_update_frontmatter': + case 'obsidian_get_notes_info': + case 'obsidian_get_frontmatter': + case 'obsidian_manage_tags': + case 'obsidian_get_vault_stats': + case 'obsidian_list_all_tags': + case 'obsidian_wiki_link': + case 'obsidian_get_note_outline': + case 'obsidian_read_note_lines': + return this.callObsidianTool(token, name, input, signal) } } @@ -1662,18 +1747,20 @@ export class KnowledgeMcpGateway { }] } } + const result = await this.callScopedTool( + token, + name as ScopedDataToolName, + parsedInput.data, + extra.signal + ) + if (name.startsWith('obsidian_')) { + return result as CallToolResult + } return { content: [ { type: 'text' as const, - text: JSON.stringify( - await this.callScopedTool( - token, - name as ScopedDataToolName, - parsedInput.data, - extra.signal - ) - ) + text: JSON.stringify(result) } ] } @@ -1913,6 +2000,7 @@ export class KnowledgeMcpGateway { for (const token of [...this.capabilities.keys()]) { this.revoke(token) } + await Promise.allSettled([...this.activeObsidianCalls]) await Promise.allSettled([...this.downstreamMcpCleanups]) await Promise.allSettled([...this.customMcpCleanups]) const server = this.server diff --git a/src/main/agent/model-tool-provider.obsidian.test.ts b/src/main/agent/model-tool-provider.obsidian.test.ts new file mode 100644 index 00000000..b0741695 --- /dev/null +++ b/src/main/agent/model-tool-provider.obsidian.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment node +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { z } from 'zod' +import { obsidianReadToolNames, obsidianScopedDataTools } from '../../shared/obsidian-tools' +import type { ResolvedMcpServer } from '../capabilities/capability-service' +import type { KnowledgeService } from '../knowledge/knowledge-service' +import { ObsidianService } from '../obsidian/obsidian-service' +import { packageObsidianMcpVault } from '../obsidian/package-mcpvault' +import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' +import { ModelToolProvider, type ModelToolCallContext, type ModelToolResult } from './model-tool-provider' + +let root: string +let vaultPath: string +let secondVaultPath: string +let service: ObsidianService +const fixtures: { provider: ModelToolProvider; gateway: KnowledgeMcpGateway }[] = [] + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-model-')) + vaultPath = join(root, 'First Vault') + secondVaultPath = join(root, 'Second Vault') + const config = join(root, 'config') + const bin = join(root, 'managed-bin') + await Promise.all([vaultPath, secondVaultPath, bin, join(config, 'obsidian')] + .map((path) => mkdir(path, { recursive: true }))) + await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: { + first: { path: vaultPath }, second: { path: secondVaultPath } + } })) + await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'), + process.platform === 'win32' + ? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n` + : `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`, + { mode: 0o755 }) + const appPath = join(root, 'app') + await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault')) + service = new ObsidianService({ + appPath, + launchEnvironmentProvider: () => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }), + platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config } + }) +}, 60_000) + +afterEach(async () => { + await Promise.all(fixtures.splice(0).map(async ({ provider, gateway }) => { + await provider.dispose() + await gateway.dispose() + })) + vi.restoreAllMocks() +}) +afterAll(async () => { await rm(root, { recursive: true, force: true }) }) + +function fixture(access: 'read' | 'write' = 'write') { + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service }) + const signal = new AbortController().signal + const token = gateway.grant('obsidian-model-test', [], signal, 'none', undefined, + undefined, undefined, undefined, { settings: {}, access })! + const provider = new ModelToolProvider(root, [], undefined, gateway) + fixtures.push({ provider, gateway }) + const context: ModelToolCallContext = { + conversationId: 'obsidian-model-test', runtimeTarget: 'model', + workMode: 'execute', knowledgeCapabilityToken: token + } + return { gateway, provider, token, signal, context, + call: (name: string, args: Record = {}) => provider.callTool(name, args, signal, context) } +} + +function text(result: ModelToolResult): string { + expect(result.contextBytes).toBe(result.parts.reduce((sum, part) => + sum + Buffer.byteLength(part.type === 'text' ? part.text : part.data), 0)) + return result.parts.map((part) => part.type === 'text' ? part.text : '').join('\n') +} + +describe('ModelToolProvider with bundled Obsidian service', () => { + it('discovers and executes all 19 tools through a scoped token, with real filesystem effects', async () => { + const { provider, context, signal, call } = fixture() + const tools = (await provider.listTools(context, signal)).filter((tool) => tool.name.startsWith('obsidian_')) + expect(tools).toHaveLength(19) + expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort()) + const executed = new Set() + const run = async (name: string, args: Record = {}) => { + const result = text(await call(name, args)) + executed.add(name) + return result + } + expect(JSON.parse(await run('obsidian_list_vaults')).vaults).toEqual([ + { id: 'first', name: 'First Vault', path: vaultPath }, + { id: 'second', name: 'Second Vault', path: secondVaultPath } + ]) + await expect(call('obsidian_write_note', { path: 'model.md', content: 'ambiguous' })) + .rejects.toThrow('vaultId is required') + await expect(call('obsidian_read_note', { vaultId: 'unknown', path: 'model.md' })) + .rejects.toThrow('Unknown Obsidian vaultId') + const target = { vaultId: 'first', path: 'model.md' } + await run('obsidian_write_note', { ...target, content: '# Original\nBody', frontmatter: { title: 'Model' } }) + expect(await readFile(join(vaultPath, 'model.md'), 'utf8')).toContain('# Original') + await expect(readFile(join(secondVaultPath, 'model.md'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.parse(await run('obsidian_read_note', target)).content).toContain('# Original') + expect(await run('obsidian_list_directory', { vaultId: 'first' })).toContain('model.md') + await run('obsidian_patch_note', { ...target, oldString: 'Original', newString: 'Patched' }) + await run('obsidian_update_frontmatter', { ...target, frontmatter: { status: 'verified' } }) + await run('obsidian_manage_tags', { ...target, operation: 'add', tags: ['integration'] }) + expect(await run('obsidian_get_frontmatter', target)).toContain('verified') + expect(await run('obsidian_search_notes', { vaultId: 'first', query: 'Patched' })).toContain('model.md') + expect(await run('obsidian_read_multiple_notes', { vaultId: 'first', paths: ['model.md'] })).toContain('Patched') + expect(await run('obsidian_get_notes_info', { vaultId: 'first', paths: ['model.md'] })).toContain('model.md') + expect(await run('obsidian_get_vault_stats', { vaultId: 'first' })).toContain('model.md') + expect(await run('obsidian_list_all_tags', { vaultId: 'first' })).toContain('integration') + expect(await run('obsidian_wiki_link', { vaultId: 'first', document: '[[model]]' })).toContain('Patched') + expect(await run('obsidian_get_note_outline', target)).toContain('Patched') + expect(await run('obsidian_read_note_lines', { ...target, startLine: 1, endLine: 20 })).toContain('Patched') + await run('obsidian_move_note', { vaultId: 'first', oldPath: 'model.md', newPath: 'renamed.md' }) + expect(await readFile(join(vaultPath, 'renamed.md'), 'utf8')).toContain('# Patched') + await writeFile(join(vaultPath, 'attachment.bin'), Buffer.from([0, 1, 255])) + await run('obsidian_move_file', { vaultId: 'first', oldPath: 'attachment.bin', newPath: 'moved.bin', + confirmOldPath: 'attachment.bin', confirmNewPath: 'moved.bin' }) + expect(await readFile(join(vaultPath, 'moved.bin'))).toEqual(Buffer.from([0, 1, 255])) + await run('obsidian_delete_note', { vaultId: 'first', path: 'renamed.md', confirmPath: 'renamed.md' }) + await expect(readFile(join(vaultPath, 'renamed.md'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await run('obsidian_list_directory', { vaultId: 'first' })).not.toContain('renamed.md') + expect([...executed].sort()).toEqual(tools.map((tool) => tool.name).sort()) + }, 60_000) + + it('enforces Ask, read-only grants, missing tokens and revoked tokens at the provider boundary', async () => { + const { provider, gateway, context, signal, token, call } = fixture() + const ask = { ...context, workMode: 'ask' as const } + expect((await provider.listTools(ask, signal)).filter((tool) => tool.name.startsWith('obsidian_')) + .map((tool) => tool.name).sort()).toEqual([...obsidianReadToolNames].sort()) + const args = { vaultId: 'first', path: 'blocked.md', content: 'blocked' } + await expect(provider.callTool('obsidian_write_note', args, signal, ask)).rejects.toThrow('Ask') + await expect(fixture('read').call('obsidian_write_note', args)).rejects.toThrow('capability is unavailable') + const unscoped = { ...context, knowledgeCapabilityToken: undefined } + expect((await provider.listTools(unscoped, signal)).some((tool) => tool.name.startsWith('obsidian_'))).toBe(false) + await expect(provider.callTool('obsidian_list_vaults', {}, signal, unscoped)).rejects.toThrow() + gateway.revoke(token) + await expect(call('obsidian_list_vaults')).rejects.toThrow() + await expect(readFile(join(vaultPath, 'blocked.md'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('awaits the active bundled child cleanup when the gateway is disposed', async () => { + const { gateway, call } = fixture() + let markStarted!: (transport: StdioClientTransport) => void + const started = new Promise((resolve) => { markStarted = resolve }) + const closed = new Set() + const close = StdioClientTransport.prototype.close + vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) { + await close.call(this) + closed.add(this) + }) + const send = StdioClientTransport.prototype.send + vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) { + const result = send.call(this, message) + if ('method' in message && message.method === 'tools/call') markStarted(this) + return result + }) + const pending = call('obsidian_list_directory', { vaultId: 'first' }) + .then(() => undefined, (error: unknown) => error) + const transport = await Promise.race([ + started, + pending.then(() => { throw new Error('Tool call ended before upstream dispatch') }) + ]) + expect(transport.pid).not.toBeNull() + try { + await gateway.dispose() + // The SDK clears pid at the start of close(), before awaiting process exit. + expect(closed.has(transport), 'dispose must await the actual transport close promise').toBe(true) + } finally { + expect(await pending).toBeInstanceOf(Error) + expect(closed.has(transport)).toBe(true) + expect(transport.pid).toBeNull() + } + }) + + it('keeps upstream defaulted arguments optional in model discovery', async () => { + const { provider, context, signal } = fixture() + const tools = await provider.listTools(context, signal) + for (const definition of obsidianScopedDataTools) { + const expected = z.toJSONSchema(definition.inputSchema, { io: 'input' }) + expect.soft(tools.find((tool) => tool.name === definition.name)?.inputSchema.required ?? [], definition.name) + .toEqual(expected.required ?? []) + } + }) + + it('does not spend the custom MCP budget on disabled Obsidian tools', async () => { + const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service }) + const servers: ResolvedMcpServer[] = Array.from({ length: 4 }, (_, index) => ({ + id: `00000000-0000-4000-8000-00000000000${index}`, name: `Vault ${index}`, + description: '', enabled: true, allowDynamicTools: false, assignments: ['model'], + secretConfigured: false, transport: 'stdio', command: 'node', + args: [join(root, 'app', 'out', 'main', 'obsidian-mcpvault', 'node_modules', + '@bitbonsai', 'mcpvault', 'dist', 'server.js'), vaultPath] + })) + const provider = new ModelToolProvider(root, servers, undefined, gateway, false, {}, () => ({ + PATH: `${join(root, 'managed-bin')}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` + })) + fixtures.push({ provider, gateway }) + // No scoped grant: 72 real upstream tools plus two workspace tools fit below 100. + const tools = await provider.listTools({ conversationId: 'disabled-obsidian', workMode: 'execute' }, + new AbortController().signal) + expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(72) + expect(tools.some((tool) => tool.name.startsWith('obsidian_'))).toBe(false) + expect(tools).toHaveLength(74) + }, 30_000) + + it.each(['read_note', 'delete_note'] as const)('preserves the native %s error detail for the model', async (name) => { + const { call } = fixture() + const args = name === 'read_note' ? { path: 'missing.md' } + : { path: 'protected.md', confirmPath: 'wrong.md' } + if (name === 'delete_note') await call('obsidian_write_note', { vaultId: 'first', path: args.path, content: 'keep me' }) + // Establish the actual bundled server error, without injecting a service or gateway double. + const native = await service.callTool({}, { vaultId: 'first', name, arguments: args }) + expect(native.isError).toBe(true) + const detail = native.content.map((part) => part.type === 'text' ? part.text : '').join('\n') + expect(detail).toContain(name === 'read_note' ? 'missing.md' : 'confirmPath') + const failure = await call(`obsidian_${name}`, { vaultId: 'first', ...args }).catch((error: unknown) => error) + if (name === 'delete_note') expect(await readFile(join(vaultPath, args.path), 'utf8')).toContain('keep me') + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toContain(detail) + }, 30_000) +}) diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index b5db78d7..b44e2f11 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -19,6 +19,7 @@ import { type WorkspaceAccess } from '../workspace' import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' +import { scopedDataToolByName } from '../../shared/scoped-data-tools' import { LocalDirectModelProcessService, type DirectModelProcessService, @@ -1008,29 +1009,13 @@ describe('ModelToolProvider', () => { }) }) - it('reserves all scoped data tool slots for Execute', async () => { + it('reserves the granted scoped data tool slots for Execute', async () => { const workspace = await createWorkspace() const gateway = { listLibraries: vi.fn(() => []), search: vi.fn(async () => []), searchMagicNotes: vi.fn(() => []), - getAvailableToolNames: vi.fn(() => [ - 'knowledge_list', - 'knowledge_search', - 'note_list', - 'note_get', - 'note_search', - 'note_create', - 'note_update', - 'note_entry_create', - 'note_entry_update', - 'note_entry_delete', - 'note_delete', - 'goodbuddy_config_capabilities', - 'goodbuddy_config_get', - 'goodbuddy_config_plan', - 'goodbuddy_config_apply' - ]) + getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway const context = { conversationId: 'knowledge-capacity', @@ -1050,7 +1035,7 @@ describe('ModelToolProvider', () => { })) mocks.client.listTools.mockResolvedValueOnce({ - tools: createTools(81) + tools: createTools(100 - 4 - scopedDataToolByName.size) }) const validProvider = new ModelToolProvider( workspace, @@ -1066,7 +1051,7 @@ describe('ModelToolProvider', () => { await validProvider.dispose() mocks.client.listTools.mockResolvedValueOnce({ - tools: createTools(82) + tools: createTools(100 - 4 - scopedDataToolByName.size + 1) }) const overflowingProvider = new ModelToolProvider( workspace, @@ -1081,10 +1066,77 @@ describe('ModelToolProvider', () => { context, new AbortController().signal ) - ).resolves.toHaveLength(19) + ).resolves.toHaveLength(4 + scopedDataToolByName.size) await overflowingProvider.dispose() }) + it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => { + const gateway = { + getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) + } as unknown as KnowledgeMcpGateway + const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)], + undefined, gateway, false, { ripgrepExecutablePath: rgPath }) + const unscoped = { ...toolContext, runtimeTarget: 'model' as const } + const scoped = { ...unscoped, knowledgeCapabilityToken: 'scoped' } + const signal = new AbortController().signal + const catalog = (count: number) => ({ tools: Array.from({ length: count }, (_, index) => ({ + name: `remote_${index}`, inputSchema: { type: 'object' } + })) }) + mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) + mocks.client.listTools.mockResolvedValue(catalog(90)) + try { + // Start with the narrower request to ensure it cannot poison later discovery. + await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) + const tools = await provider.listTools(unscoped, signal) + expect(tools).toHaveLength(94) + const name = tools.find((tool) => tool.source === 'mcp')!.name + await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) + await expect(provider.callTool(name, {}, signal, scoped)).rejects.toThrow('未知工具') + await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(94) + await expect(provider.callTool(name, {}, signal, unscoped)).resolves.toBeDefined() + expect(mocks.client.connect).toHaveBeenCalledOnce() + expect(mocks.client.listTools).toHaveBeenCalledOnce() + + const options = mocks.Client.mock.calls[0]![1] as { + listChanged: { tools: { onChanged(error: Error | null): void } } + } + mocks.client.listTools.mockResolvedValue(catalog(96)) + options.listChanged.tools.onChanged(null) + await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) + await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(100) + expect(mocks.client.listTools).toHaveBeenCalledTimes(2) + + mocks.client.listTools.mockResolvedValue(catalog(100 - 4 - scopedDataToolByName.size)) + options.listChanged.tools.onChanged(null) + await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(100) + expect(mocks.client.connect).toHaveBeenCalledOnce() + } finally { + await provider.dispose() + } + }) + + it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => { + const gateway = { + getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) + } as unknown as KnowledgeMcpGateway + mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({ + name: `remote_${index}`, inputSchema: { type: 'object' } + })) }) + const provider = new ModelToolProvider(await createWorkspace(), [ + createMcpServer(), { ...createMcpServer(), id: 'second', name: 'Second' } + ], undefined, gateway) + const signal = new AbortController().signal + try { + await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82) + await expect(provider.listTools({ ...toolContext, knowledgeCapabilityToken: 'scoped' }, signal)) + .rejects.toThrow('100') + await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82) + expect(mocks.client.listTools).toHaveBeenCalledTimes(2) + } finally { + await provider.dispose() + } + }) + it('loads every paginated MCP tool before exposing the catalog', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockImplementation( @@ -2151,6 +2203,38 @@ describe('ModelToolProvider', () => { ).rejects.toThrow(message) }) + it.each([false, true])('preserves bounded native MCP error text (wrapped: %s)', async (wrapped) => { + mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'read', inputSchema: { type: 'object' } }] }) + const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()]) + const signal = new AbortController().signal + try { + const tools = await provider.listTools(toolContext, signal) + const name = tools.find((tool) => tool.source === 'mcp')!.name + for (const detail of ['File not found: note.md', '\u4e2d'.repeat(100_000), '']) { + const result = { isError: true, content: [ + { type: 'text', text: detail }, { type: 'text', text: 'Use list_directory.' } + ] } + mocks.client.callTool.mockResolvedValue(wrapped ? { toolResult: result } : result) + const error = await provider.callTool(name, {}, signal, toolContext).catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain('MCP Server') + expect(Buffer.byteLength(message)).toBeLessThanOrEqual(256 * 1024) + if (Buffer.byteLength(detail) > 256 * 1024) { + expect(message).toContain(detail.slice(0, 100)) + expect(message).toContain('[GoodBuddy result truncated]') + expect(message).not.toContain('\ufffd') + } else { + expect(message).toContain(`${detail}\nUse list_directory.`) + } + } + mocks.client.callTool.mockResolvedValue({ isError: true, content: [] }) + await expect(provider.callTool(name, {}, signal, toolContext)).rejects.toThrow('MCP Server') + } finally { + await provider.dispose() + } + }) + it('counts encoded and decoded image data against the MCP result budget', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 9192cdca..efb97131 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -6,7 +6,6 @@ import { builtinModelTools } from '../../shared/builtin-model-tools' import { goodbuddyConfigWriteToolNames, magicNoteWriteToolNames, - maximumScopedToolCount, scopedDataToolByName, scopedReadToolNames, type ScopedDataToolName @@ -121,7 +120,7 @@ const scopedToolJsonSchemas = new Map( [...scopedDataToolByName].map(([name, definition]) => { const schema = z.toJSONSchema( definition.inputSchema, - { target: 'draft-7' } + { target: 'draft-7', io: 'input' } ) as Record Reflect.deleteProperty(schema, '$schema') return [name, schema] as const @@ -539,7 +538,15 @@ function normalizeMcpResult(result: unknown): ModelToolResult { } const record = result as Record if (record.isError === true) { - throw new Error('MCP Server 报告工具执行失败') + const detail = Array.isArray(record.content) + ? record.content.slice(0, MAX_MCP_CONTENT_BLOCKS).flatMap((item) => + item?.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + ).join('\n') + : '' + const message = `MCP Server 报告工具执行失败${detail ? `\n${detail}` : ''}` + throw new Error(Buffer.byteLength(message) > MAX_TOOL_RESULT_BYTES + ? utf8Prefix(message, MAX_TOOL_RESULT_BYTES - Buffer.byteLength(TOOL_RESULT_TRUNCATION_MARKER)) + TOOL_RESULT_TRUNCATION_MARKER + : message) } if ('toolResult' in record) { if ( @@ -734,7 +741,7 @@ export class ModelToolProvider implements ModelToolProviderLike { return tools } - private getReservedToolCount(): number { + private getReservedToolCount(scopedToolCount = 0): number { return ( this.getBuiltinTools().length + (this.browserService ? browserToolNames.length : 0) + @@ -743,7 +750,7 @@ export class ModelToolProvider implements ModelToolProviderLike { (this.programming.subagentService ? 1 : 0) + (this.programming.ripgrepExecutablePath || this.programming.processService || this.programming.subagentService ? 1 : 0) + - (this.knowledgeGateway ? maximumScopedToolCount : 0) + scopedToolCount ) } @@ -1097,6 +1104,7 @@ export class ModelToolProvider implements ModelToolProviderLike { private async getMcpBindings( signal: AbortSignal, + scopedToolCount: number, refreshDynamic = false ): Promise> { signal.throwIfAborted() @@ -1162,8 +1170,12 @@ export class ModelToolProvider implements ModelToolProviderLike { } signal.throwIfAborted() const bindings = new Map() - const reservedToolCount = this.getReservedToolCount() + const reservedToolCount = this.getReservedToolCount(scopedToolCount) for (const connection of connections) { + // Cached catalogs are shared; a request's grant must not shrink the cache. + if (connection.tools.length > MAX_MODEL_TOOLS - reservedToolCount) { + continue + } for (const binding of connection.tools) { if (bindings.size + reservedToolCount >= MAX_MODEL_TOOLS) { throw new Error('直连模型工具总数超过 100 个安全限制') @@ -1260,7 +1272,7 @@ export class ModelToolProvider implements ModelToolProviderLike { ] } const processTools = await this.getProcessTool(context, signal) - const bindings = await this.getMcpBindings(signal, true) + const bindings = await this.getMcpBindings(signal, scopedTools.length, true) const browserTools = this.getBrowserTools(context) return [ ...workspaceTools, @@ -1394,6 +1406,17 @@ export class ModelToolProvider implements ModelToolProviderLike { ): Promise { signal.throwIfAborted() assertToolAuthorizedForWorkMode(name, context) + if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) { + if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) { + throw new Error('Obsidian 工具授权不可用') + } + return normalizeMcpResult(await this.knowledgeGateway.callObsidianTool( + context.knowledgeCapabilityToken, + name as Extract, + argumentsValue, + signal + )) + } if (name === 'generate_image') { if (!context.imageToolBinding || !context.toolCallId) throw new Error('Image tool request binding is unavailable') return createTextToolResult(JSON.stringify(await context.imageToolBinding.call(argumentsValue, context.toolCallId, signal))) diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index 6646653d..46f8761a 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -137,6 +137,54 @@ afterEach(async () => { }) describe('CapabilityService', () => { + it('persists Obsidian scope independently of enablement and assignments', async () => { + const { service, filePath, builtinRoot, importedRoot } = await createService() + expect(await service.getObsidianSettings()).toEqual({ vaultPath: '' }) + const saved = await service.updateObsidianSettings({ vaultPath: ' /notes/work ' }) + expect(saved.obsidian).toEqual({ vaultPath: '/notes/work' }) + expect(saved.builtinMcpServers).toContainEqual({ + id: 'obsidian', enabled: false, assignments: ['model', 'opencode', 'continue'] + }) + await service.setBuiltinMcpServerEnabled('obsidian', true) + await service.setBuiltinMcpServerAssignments('obsidian', ['continue']) + const reloaded = new CapabilityService(filePath, builtinRoot, importedRoot, cipher) + expect(await reloaded.getObsidianSettings()).toEqual({ vaultPath: '/notes/work' }) + expect((await reloaded.getSnapshot()).builtinMcpServers).toContainEqual({ + id: 'obsidian', enabled: true, assignments: ['continue'] + }) + const settings = await reloaded.getObsidianSettings() + settings.vaultPath = '/mutated' + expect(await reloaded.getObsidianSettings()).toEqual({ vaultPath: '/notes/work' }) + await expect(reloaded.updateObsidianSettings({ vaultPath: '', readOnly: true } as never)).rejects.toThrow() + expect((await reloaded.updateObsidianSettings({ vaultPath: ' ' })).obsidian).toEqual({ vaultPath: '' }) + }) + + it.each([1, 5, 6])('loads version %i without enabling Obsidian or losing existing settings', async (version) => { + const { service, filePath, builtinRoot, importedRoot } = await createService() + await service.setBuiltinMcpServerEnabled('knowledge-base', false) + const stored = JSON.parse(await readFile(filePath, 'utf8')) + delete stored.obsidian + delete stored.builtinMcpServers.obsidian + stored.version = version + if (version === 5) delete stored.builtinMcpServers['builtin-browser'] + if (version === 1) { + delete stored.builtinMcpServers + delete stored.webSearch + delete stored.computerCapabilities + } + await writeFile(filePath, JSON.stringify(stored)) + const reloaded = new CapabilityService(filePath, builtinRoot, importedRoot, cipher) + const snapshot = await reloaded.getSnapshot() + expect(snapshot.obsidian).toEqual({ vaultPath: '' }) + expect(snapshot.warnings).toBeUndefined() + expect(snapshot.builtinMcpServers).toContainEqual({ + id: 'obsidian', enabled: false, assignments: ['model', 'opencode', 'continue'] + }) + if (version !== 1) expect(snapshot.builtinMcpServers).toContainEqual({ + id: 'knowledge-base', enabled: false, assignments: ['model', 'opencode', 'continue'] + }) + }) + it('derives image assignments from saved profiles without persisting or exposing connection data', async () => { const image = { id: '00000000-0000-4000-8000-000000000001', name: 'Image', protocol: 'openai-images-generations', allowConversationInvocation: true, apiKey: 'must-not-leak' } let profiles = [image] @@ -293,6 +341,11 @@ describe('CapabilityService', () => { id: 'builtin-browser', enabled: false, assignments: ['model', 'opencode', 'continue'] + }, + { + id: 'obsidian', + enabled: false, + assignments: ['model', 'opencode', 'continue'] } ] }) diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index 8a51a025..2fb54c51 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -28,6 +28,8 @@ import { mcpServerIdSchema, mcpServerInputSchema, mcpServerSummarySchema, + obsidianSettingsSchema, + type ObsidianSettings, runtimeTargetSchema, skillIdSchema, skillSummarySchema, @@ -132,7 +134,11 @@ const legacyBuiltinMcpServerStatesSchema = z const builtinMcpServerStatesSchema = legacyBuiltinMcpServerStatesSchema.extend({ - 'builtin-browser': builtinMcpServerStateSchema + 'builtin-browser': builtinMcpServerStateSchema, + obsidian: builtinMcpServerStateSchema.default({ + enabled: false, + assignments: ['model', 'opencode', 'continue'] + }) }) const encryptedSecretSchema = @@ -234,7 +240,8 @@ const storedCapabilitiesV5Schema = storedCapabilitiesV4Schema.extend({ const storedCapabilitiesSchema = storedCapabilitiesV5Schema.extend({ version: z.literal(6), - builtinMcpServers: builtinMcpServerStatesSchema + builtinMcpServers: builtinMcpServerStatesSchema, + obsidian: obsidianSettingsSchema.default({ vaultPath: '' }) }) type StoredCapabilitiesV1 = z.infer @@ -312,6 +319,7 @@ function defaultBuiltinMcpServerStates( 'knowledge-base': defaultState(), 'magic-notes': defaultState(), 'goodbuddy-config': defaultState(), + obsidian: { ...defaultState(), enabled: false }, 'builtin-browser': { enabled: browserEnabled, assignments: ['model', 'opencode', 'continue'] @@ -324,6 +332,7 @@ function emptyStoredCapabilities( ): StoredCapabilities { return { version: 6, + obsidian: { vaultPath: '' }, skills: {}, builtinMcpServers: defaultBuiltinMcpServerStates(), mcpServers: [], @@ -822,6 +831,7 @@ export class CapabilityService { storedCapabilitiesV1Schema.parse(raw) loaded = { version: 6, + obsidian: { vaultPath: '' }, skills: legacy.skills, builtinMcpServers: defaultBuiltinMcpServerStates(), mcpServers: legacy.mcpServers, @@ -834,6 +844,7 @@ export class CapabilityService { loaded = { ...legacy, version: 6, + obsidian: { vaultPath: '' }, builtinMcpServers: defaultBuiltinMcpServerStates( legacy.computerCapabilities['host-browser-control'].enabled ), @@ -845,6 +856,7 @@ export class CapabilityService { loaded = { ...legacy, version: 6, + obsidian: { vaultPath: '' }, builtinMcpServers: defaultBuiltinMcpServerStates( legacy.computerCapabilities['host-browser-control'].enabled ) @@ -855,6 +867,7 @@ export class CapabilityService { loaded = { ...legacy, version: 6, + obsidian: { vaultPath: '' }, builtinMcpServers: defaultBuiltinMcpServerStates( legacy.computerCapabilities['host-browser-control'].enabled ) @@ -865,7 +878,9 @@ export class CapabilityService { loaded = { ...legacy, version: 6, + obsidian: { vaultPath: '' }, builtinMcpServers: { + obsidian: defaultBuiltinMcpServerStates().obsidian, ...legacy.builtinMcpServers, 'builtin-browser': { enabled: @@ -999,6 +1014,7 @@ export class CapabilityService { ? ['model', 'opencode', 'continue', 'deepseek-harness'] : [] }, + obsidian: { ...state.obsidian }, skills: catalog .map((skill) => ({ ...skill, @@ -1068,6 +1084,19 @@ export class CapabilityService { .digest('hex') } + async getObsidianSettings(): Promise { + return { ...(await this.load()).obsidian } + } + + updateObsidianSettings(input: ObsidianSettings): Promise { + return this.queue(async () => { + const obsidian = obsidianSettingsSchema.parse(input) + const state = await this.load() + await this.persistUserChange({ ...state, obsidian }) + return this.getSnapshot() + }) + } + async getWebSearchCapabilityStatus(): Promise<{ enabled: boolean }> { const state = await this.load() return { enabled: state.webSearch.enabled } diff --git a/src/main/index.ts b/src/main/index.ts index 1e3a9cb9..633b9cb7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import type { AgentRuntime } from './agent/runtime' import { SelectedRuntimeManager } from './agent/selected-runtime-manager' import { LocalRuntimeRegistry } from './agent/local-runtime-registry' import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway' +import { ObsidianService } from './obsidian' import { applyRuntimeSelection, getConfiguredRuntimeTarget, @@ -965,11 +966,17 @@ if (hasSingleInstanceLock) { applicationSettingsStore, capabilityService ) + const obsidianService = new ObsidianService({ + appPath: app.getAppPath(), + launchEnvironmentProvider: + startupLocalToolEnvironmentService.launchEnvironmentProvider + }) const startupKnowledgeGateway = new KnowledgeMcpGateway( startupKnowledgeService, { magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService, + obsidianService, browserService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider @@ -1477,7 +1484,8 @@ if (hasSingleInstanceLock) { startupRemoteAgentServices.connectionManager, terminalSessionManager, startupLocalToolEnvironmentService, - imageGenerationService + imageGenerationService, + obsidianService ) removeFeedbackIpcHandler = registerFeedbackIpcHandler( mainWindow, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index ac9ca813..f3714fc0 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1,14 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type {} from '@testing-library/jest-dom/vitest' import { EventEmitter } from 'node:events' import { mkdtemp, + mkdir, readFile, + readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, dirname, join } from 'node:path' import { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject, @@ -36,6 +39,9 @@ import { KnowledgeService } from './knowledge/knowledge-service' import { KnowledgeDatabase } from './knowledge/knowledge-database' import { BrowserNavigationStoppedError } from './browser/browser-service' import { RemotePromptCancelledError } from './agent/acp-remote-runtime' +import { CapabilityService } from './capabilities/capability-service' +import { ObsidianService } from './obsidian' +import { packageObsidianMcpVault } from './obsidian/package-mcpvault' import { registerIpcHandlers, sendRemoteEnvironmentUpdateProgress, @@ -5109,7 +5115,8 @@ describe('registerIpcHandlers agent terminal state', () => { browserControl?: Record, imageService?: ImageGenerationService, imageDatabase?: AssistantDatabase, - heartbeatEnabled?: boolean + heartbeatEnabled?: boolean, + obsidianService?: ObsidianService ) { const assistantDatabase = { createTask: vi.fn(), @@ -5340,6 +5347,7 @@ describe('registerIpcHandlers agent terminal state', () => { goodbuddyConfigService as never ] args[43] = imageService + args[44] = obsidianService const dispose = registerIpcHandlers(...args) return { approvalBroker, @@ -8629,6 +8637,178 @@ describe('registerIpcHandlers agent terminal state', () => { } }) + it('tests an unsaved Obsidian UI draft through registered IPC and bundled MCPVault, then saves and refreshes', async () => { + const { createElement } = await import('react') + const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure') + // Load the renderer at test runtime without pulling its JSX project into the Node typecheck. + const rendererModule = '../renderer/src/McpSettingsSection' + const { McpSettingsSection } = await import(rendererModule) as { + McpSettingsSection: import('react').ComponentType<{ onOpenImageModelSettings: () => void }> + } + const { default: i18n } = await import('i18next') + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-')) + let harness: ReturnType | undefined + try { + const vaultPath = join(root, 'Draft Vault') + await mkdir(vaultPath) + await writeFile(join(vaultPath, 'existing.md'), '# Keep this note\n') + const appPath = join(root, 'app.asar') + await packageObsidianMcpVault(process.cwd(), join(root, 'app.asar.unpacked', 'out', 'main', 'obsidian-mcpvault')) + const service = new ObsidianService({ + appPath, + launchEnvironmentProvider: () => ({ PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }) + }) + const capabilities = new CapabilityService( + join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'), + { isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() } + ) + harness = createHarness( + { capability: 'chat' }, undefined, 'always', undefined, false, + undefined, undefined, undefined, false, undefined, + { + getSnapshot: capabilities.getSnapshot.bind(capabilities), + updateObsidianSettings: capabilities.updateObsidianSettings.bind(capabilities) + }, + undefined, undefined, undefined, undefined, service + ) + const event = trustedEvent(harness.webContents) + const test = electronMocks.handlers.get(ipcChannels.capabilitiesTestObsidianConnection)! + const save = electronMocks.handlers.get(ipcChannels.capabilitiesUpdateObsidianSettings)! + const pick = electronMocks.handlers.get(ipcChannels.capabilitiesSelectObsidianVault)! + expect(test).toBeTypeOf('function') + expect(save).toBeTypeOf('function') + expect(pick).toBeTypeOf('function') + + // Replace only Electron transport; UI, handlers, persistence and MCPVault are real. + const invoke = async (channel: string, input?: unknown) => + electronMocks.handlers.get(channel)!(event, input) + vi.stubGlobal('goodbuddy', { capabilities: { + getSnapshot: () => invoke(ipcChannels.capabilitiesSnapshot), + testObsidianConnection: (input: unknown) => invoke(ipcChannels.capabilitiesTestObsidianConnection, input), + updateObsidianSettings: (input: unknown) => invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input), + selectObsidianVault: () => invoke(ipcChannels.capabilitiesSelectObsidianVault) + } }) + const view = render(createElement(McpSettingsSection, { onOpenImageModelSettings: vi.fn() })) + const testButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') }) + await waitFor(() => expect(testButton).toBeEnabled()) + fireEvent.change(view.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'folder' } }) + const pathInput = view.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') }) + fireEvent.change(pathInput, { target: { value: vaultPath } }) + fireEvent.click(testButton) + await waitFor(() => { + expect(view.getByText(vaultPath)).toBeVisible() + expect(testButton).toBeEnabled() + }, { timeout: 10_000 }) + expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' }) + expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled() + expect(await readdir(vaultPath)).toEqual(['existing.md']) + expect(await readFile(join(vaultPath, 'existing.md'), 'utf8')).toBe('# Keep this note\n') + + electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [join(root, 'Cancelled Vault')] }) + const pickButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') }) + fireEvent.click(pickButton) + await waitFor(() => { + expect(electronMocks.showOpenDialog).toHaveBeenCalledOnce() + expect(pickButton).toBeEnabled() + expect(pathInput).toHaveValue(vaultPath) + }) + electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [vaultPath] }) + await expect(pick(event)).resolves.toBe(vaultPath) + expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith( + expect.objectContaining({ webContents: harness.webContents }), { properties: ['openDirectory'] } + ) + expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' }) + + fireEvent.click(view.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })) + await waitFor(() => { + expect(view.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: vaultPath }))).toBeVisible() + expect(testButton).toBeEnabled() + }) + expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce() + harness.onRuntimeSettingsChanged.mockClear() + await expect(save(event, { vaultPath: ` ${vaultPath} ` })).resolves.toMatchObject({ + obsidian: { vaultPath }, + builtinMcpServers: expect.arrayContaining([expect.objectContaining({ id: 'obsidian', enabled: false })]) + }) + expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath }) + expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce() + await expect(test(event, { vaultPath: join(root, 'missing') })).rejects.toThrow('missing or inaccessible') + expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath }) + expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce() + expect(() => save(event, { vaultPath: 42 })).toThrow() + await expect(test(event, { vaultPath: 42 })).rejects.toThrow() + const untrustedEvent = { ...event, sender: { ...harness.webContents, id: 99 } } + expect(() => save(untrustedEvent, { vaultPath })).toThrow('拒绝来自未知窗口的 IPC 请求') + await expect(test(untrustedEvent, { vaultPath })).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') + await expect(pick(untrustedEvent)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') + } finally { + cleanup() + vi.unstubAllGlobals() + await harness?.dispose() + await rm(root, { recursive: true, force: true }) + } + }, 60_000) + + it.each([ + { enabled: false, assigned: true, workMode: 'ask' as const, access: undefined }, + { enabled: true, assigned: false, workMode: 'execute' as const, access: undefined }, + { enabled: true, assigned: true, workMode: 'ask' as const, access: 'read' }, + { enabled: true, assigned: true, workMode: 'execute' as const, access: 'write' } + ])('routes Obsidian request grants with saved settings: %j', async ({ enabled, assigned, workMode, access }) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-grant-')) + let harness: ReturnType | undefined + try { + const capabilities = new CapabilityService( + join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'), + { isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() } + ) + const settings = { vaultPath: join(root, 'Saved Vault') } + await capabilities.updateObsidianSettings(settings) + if (enabled) await capabilities.setBuiltinMcpServerEnabled('obsidian', true) + if (!assigned) await capabilities.setBuiltinMcpServerAssignments('obsidian', ['continue']) + const received: AgentExecutionRequest[] = [] + const gateway = { + grant: vi.fn(() => 'obsidian-capability'), + getAvailableToolNames: vi.fn(() => ['obsidian_list_vaults']), + drainReferences: vi.fn(() => []), + revoke: vi.fn() + } + const getObsidianSettings = vi.fn(() => capabilities.getObsidianSettings()) + harness = createHarness({ + runtimeId: 'model', capability: 'chat', supportsToolExecution: true, + async *run(request: AgentExecutionRequest) { + received.push(request) + yield { requestId: request.requestId, type: 'done' } + } + }, undefined, 'always', undefined, false, undefined, undefined, gateway, false, undefined, { + getEnabledBuiltinMcpServerIds: capabilities.getEnabledBuiltinMcpServerIds.bind(capabilities), + getObsidianSettings + }) + const requestId = '00000000-0000-4000-8000-000000000026' + await harness.handler!(trustedEvent(harness.webContents), { + requestId, conversationId: 'obsidian-grant', prompt: 'List my vaults', workMode, knowledgeLibraryIds: [] + }) + await vi.waitFor(() => expect(harness!.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) + expect(received).toHaveLength(1) + if (access) { + expect(gateway.grant).toHaveBeenCalledExactlyOnceWith( + requestId, [], expect.any(AbortSignal), 'none', undefined, undefined, undefined, undefined, + { settings, access } + ) + expect(received[0]?.knowledgeCapabilityToken).toBe('obsidian-capability') + expect(getObsidianSettings).toHaveBeenCalledOnce() + expect(gateway.revoke).toHaveBeenCalledWith('obsidian-capability') + } else { + expect(gateway.grant).not.toHaveBeenCalled() + expect(getObsidianSettings).not.toHaveBeenCalled() + expect(received[0]?.knowledgeCapabilityToken).toBeUndefined() + } + } finally { + await harness?.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => { const runtime = { runtimeId: 'model', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e5b043fb..1a61750e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -127,6 +127,8 @@ import { computerCapabilityToggleInputSchema, mcpServerIdSchema, mcpServerInputSchema, + obsidianSettingsSchema, + obsidianConnectionTestResultSchema, skillAssignmentsInputSchema, skillIdSchema, skillImportKindSchema, @@ -136,6 +138,7 @@ import { type CapabilitySnapshot, type CapabilityDiagnosticReport, type McpServerTestResult, + type ObsidianSettings, type WebSearchTestResult } from '../shared/capability-contracts' import { @@ -158,6 +161,7 @@ import { agentPackageInventorySchema } from '../shared/agent-package-contracts' import { assertTrustedSender } from './trusted-ipc-sender' +import type { ObsidianService } from './obsidian' import { BrowserNavigationStoppedError } from './browser/browser-service' import type { TerminalSessionManager } from './terminal/terminal-session-manager' import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts' @@ -576,6 +580,7 @@ async function grantScopedDataCapability(input: { libraryIds: readonly string[] magicNotesAccess: MagicNotesCapabilityAccess configAccess?: MagicNotesCapabilityAccess + obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } workspacePath?: string browserConversationId?: string browserTabId?: BrowserTabId @@ -587,6 +592,7 @@ async function grantScopedDataCapability(input: { signal: AbortSignal }): Promise { const enabledServers = new Set(input.enabledServers) + const obsidian = enabledServers.has('obsidian') ? input.obsidian : undefined const libraryIds = enabledServers.has('knowledge-base') ? input.libraryIds : [] @@ -604,7 +610,8 @@ async function grantScopedDataCapability(input: { (libraryIds.length === 0 && magicNotesAccess === 'none' && configAccess === 'none' && - !browserConversationId) + !browserConversationId && + !obsidian) ) { return { toolNames: [] } } @@ -630,7 +637,19 @@ async function grantScopedDataCapability(input: { authorizeApply: input.authorizeConfigApply } : undefined - const token = browserConversationId + const token = obsidian + ? input.gateway.grant( + input.requestId, + libraryIds, + input.signal, + magicNotesAccess, + config, + browserConversationId, + browserTabId, + browserUsageLease, + obsidian + ) + : browserConversationId ? input.gateway.grant( input.requestId, libraryIds, @@ -1363,7 +1382,8 @@ export function registerIpcHandlers( >, terminalSessionManager?: TerminalSessionManager, localToolEnvironmentService?: LocalToolEnvironmentService, - imageGenerationService?: ImageGenerationService + imageGenerationService?: ImageGenerationService, + obsidianService?: ObsidianService ): () => Promise { type ActiveRequestLease = { controller: AbortController @@ -2594,10 +2614,16 @@ export function registerIpcHandlers( requestRuntimeTarget ) : builtinMcpServerIdSchema.options.filter( - (id) => id !== 'builtin-browser' + (id): boolean => id !== 'builtin-browser' && id !== 'obsidian' ) : [] const notesCapability = await grantScopedDataCapability({ + obsidian: enabledBuiltinMcpServers.includes('obsidian') + ? { + settings: await capabilityService.getObsidianSettings(), + access: schedule.workMode === 'execute' ? 'write' : 'read' + } + : undefined, gateway: knowledgeGateway, browserControl: browserControl as BrowserCapabilityControl | undefined, runtime: requestRuntime, @@ -4178,7 +4204,7 @@ export function registerIpcHandlers( selectedRuntimeTarget ) : builtinMcpServerIdSchema.options.filter( - (id) => id !== 'builtin-browser' + (id): boolean => id !== 'builtin-browser' && id !== 'obsidian' ) : [] ]) @@ -4211,6 +4237,12 @@ export function registerIpcHandlers( : undefined const imageToolAvailable = Boolean(await imageToolBinding?.describe()) const scopedCapability = await grantScopedDataCapability({ + obsidian: enabledBuiltinMcpServers.includes('obsidian') + ? { + settings: await capabilityService.getObsidianSettings(), + access: enrichedRequest.workMode === 'execute' ? 'write' : 'read' + } + : undefined, gateway: knowledgeGateway, browserControl: browserControl as BrowserCapabilityControl | undefined, runtime: selectedRuntime, @@ -8131,11 +8163,47 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.capabilitiesUpdateObsidianSettings, + (event, input: unknown): Promise => { + assertTrustedSender(event, window) + const settings = obsidianSettingsSchema.parse(input) + knowledgeGateway?.revokeObsidianCapabilities() + return refreshCapabilities( + capabilityService.updateObsidianSettings(settings) + ) + } + ) + + registerHandler( + ipcChannels.capabilitiesTestObsidianConnection, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const settings = obsidianSettingsSchema.parse(input) + if (!obsidianService) throw new Error('Obsidian 服务不可用') + return obsidianConnectionTestResultSchema.parse( + await obsidianService.testConnection(settings) + ) + } + ) + + registerHandler( + ipcChannels.capabilitiesSelectObsidianVault, + async (event): Promise => { + assertTrustedSender(event, window) + const result = await dialog.showOpenDialog(window, { + properties: ['openDirectory'] + }) + return result.canceled ? null : result.filePaths[0] ?? null + } + ) + registerHandler( ipcChannels.capabilitiesToggleBuiltinMcp, (event, input: unknown): Promise => { assertTrustedSender(event, window) const value = builtinMcpServerToggleInputSchema.parse(input) + if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities() return refreshCapabilities( capabilityService.setBuiltinMcpServerEnabled( value.serverId, @@ -8150,6 +8218,7 @@ export function registerIpcHandlers( (event, input: unknown): Promise => { assertTrustedSender(event, window) const value = builtinMcpServerAssignmentsInputSchema.parse(input) + if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities() return refreshCapabilities( capabilityService.setBuiltinMcpServerAssignments( value.serverId, diff --git a/src/main/obsidian/README.md b/src/main/obsidian/README.md new file mode 100644 index 00000000..8c0f9654 --- /dev/null +++ b/src/main/obsidian/README.md @@ -0,0 +1,55 @@ +# Obsidian Service + +Construct `ObsidianService` from `./obsidian` with: + +```ts +const obsidian = new ObsidianService({ + appPath: app.getAppPath(), + launchEnvironmentProvider: localToolEnvironmentService.launchEnvironmentProvider +}) +``` + +Optional `homePath`, `platform`, and `environment` overrides support isolated tests. +The provider is read on every launch. Its prepared PATH selects the managed `node` +shim, including Electron-as-Node; the service never invokes npm or npx. + +## API + +- `listVaults(settings): Promise` returns `{ id, name, path }`. +- `testConnection(settings): Promise<{ vaults: ObsidianVault[]; toolCount: number }>` + opens each vault and lists its tools without writing. `toolCount` is the count + of unique upstream tool names, not the count multiplied by the number of vaults. +- `listTools(settings): Promise` returns the complete upstream schemas + from the first vault. The gateway can wrap each tool with `vaultId`. +- `callTool(settings, { vaultId?, name, arguments }, signal?): Promise` + forwards the request unchanged. Omitting `vaultId` requires exactly one vault. + +Settings accept `{ vaultPath?: string }`. A nonempty explicit absolute folder +bypasses discovery and receives a SHA-256 path ID. Empty settings discover all +registered vaults, retaining their registry IDs, regardless of their `open` flag. +Discovery reads `obsidian/obsidian.json` beneath `%APPDATA%` on Windows, +`~/Library/Application Support` on macOS, or `$XDG_CONFIG_HOME` (default +`~/.config`) on Linux. Missing registries, empty registries, invalid folders, and +unknown IDs produce explicit errors. Ordinary folders are accepted, as upstream +does not require an `.obsidian` directory. + +Each operation creates and closes its own stdio client. Cancellation reaches both +initialization and tool requests, and cleanup is awaited even when the SDK also +initiates cleanup. No read-only option or tool filtering is added. + +## Packaging + +`@bitbonsai/mcpvault` is pinned to `0.16.0` as a build dependency. The Electron Vite +main build copies its production dependency closure to +`out/main/obsidian-mcpvault/node_modules`, preserving package-relative assets and +all included license files. Electron Builder unpacks that directory; the service +maps `app.asar` to `app.asar.unpacked` before launching the upstream `dist/server.js`. +This preserves `trash`'s Windows and macOS binaries and Linux implementation. +Runtime installation and network access are unnecessary. + +`obsidian-service.test.ts` copies the same production package to an isolated +temporary app directory, launches it via a managed Node shim, and exercises +registry discovery, every advertised schema, connection probes, note read/write, +patches, frontmatter changes, listing, local-trash deletion, and cancellation. +These tests never discover or modify the user's real vaults. Cross-platform +discovery is tested with fixtures; native runtime execution follows the host OS. diff --git a/src/main/obsidian/index.ts b/src/main/obsidian/index.ts new file mode 100644 index 00000000..fd61b926 --- /dev/null +++ b/src/main/obsidian/index.ts @@ -0,0 +1,2 @@ +export { ObsidianService } from './obsidian-service' +export type { ObsidianSettings, ObsidianVault, ObsidianServiceOptions } from './obsidian-service' diff --git a/src/main/obsidian/obsidian-electron.test.ts b/src/main/obsidian/obsidian-electron.test.ts new file mode 100644 index 00000000..1a049d14 --- /dev/null +++ b/src/main/obsidian/obsidian-electron.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, isAbsolute, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import spawn from 'cross-spawn' +import { expect, it } from 'vitest' +import { createLocalToolEnvironment } from '../local-tool-environment/local-tool-environment' +import { ObsidianService } from './obsidian-service' +import { packageObsidianMcpVault } from './package-mcpvault' + +it('runs copied MCPVault over stdio using real managed Electron without npm or network connections', async (context) => { + const projectRoot = fileURLToPath(new URL('../../../', import.meta.url)) + const electronPackage = join(projectRoot, 'node_modules', 'electron') + let electronExecutablePath: string + try { + const executable = (await readFile(join(electronPackage, 'path.txt'), 'utf8')).trim() + electronExecutablePath = await realpath(join(electronPackage, 'dist', executable)) + expect((await stat(electronExecutablePath)).isFile()).toBe(true) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + context.skip('The node_modules/electron executable is not installed') + return + } + + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-electron-')) + try { + const location = relative(projectRoot, root) + expect(isAbsolute(location) || location === '..' || location.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)).toBe(true) + const appPath = join(root, 'app.asar.unpacked') + const vaultPath = join(root, 'Temporary Vault') + const binDirectory = join(root, 'managed bin') + await mkdir(vaultPath) + const output = join(appPath, 'out', 'main', 'obsidian-mcpvault') + await packageObsidianMcpVault(projectRoot, output) + const manifest = JSON.parse(await readFile(join(output, 'node_modules', '@bitbonsai', 'mcpvault', 'package.json'), 'utf8')) + expect(manifest.version).toBe('0.16.0') + + // Only Windows shell/process utilities are needed alongside the managed shim. + const baseEnvironment: NodeJS.ProcessEnv = { PATH: '' } + if (process.platform === 'win32') { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT + expect(systemRoot).toBeTruthy() + baseEnvironment.SystemRoot = systemRoot + baseEnvironment.PATH = join(systemRoot!, 'System32') + baseEnvironment.COMSPEC = join(systemRoot!, 'System32', 'cmd.exe') + baseEnvironment.PATHEXT = '.COM;.EXE;.BAT;.CMD' + } + const prepared = await createLocalToolEnvironment({ + binDirectory, + nodeSelection: { source: 'managed' }, + pythonSelection: { source: 'managed' }, + packagedNpmCliPath: join(root, 'absent-npm-cli.js'), + packagedNpxCliPath: join(root, 'absent-npx-cli.js'), + baseEnvironment, + probeTimeoutMs: 10_000 + }, { electronExecutablePath }) + expect(prepared.diagnostics.tools.node).toMatchObject({ + available: true, source: 'managed', executablePath: electronExecutablePath, + shimPath: join(binDirectory, process.platform === 'win32' ? 'node.cmd' : 'node') + }) + expect(prepared.environment.PATH?.split(delimiter)).toEqual([ + binDirectory, ...(baseEnvironment.PATH ? [baseEnvironment.PATH] : []) + ]) + for (const name of ['npm', 'npx'] as const) { + expect(prepared.diagnostics.tools[name].available).toBe(false) + const probe = spawn.sync(name, ['--version'], { + cwd: vaultPath, env: { ...baseEnvironment, ...prepared.environment }, + encoding: 'utf8', timeout: 5_000, windowsHide: true + }) + expect((probe.error as NodeJS.ErrnoException | undefined)?.code).toBe('ENOENT') + } + + const auditPath = join(root, 'children.jsonl') + const attemptsPath = join(root, 'network-attempts') + const preload = join(root, 'offline preload.cjs') + await writeFile(attemptsPath, '') + // NODE_OPTIONS is passed through the real service filter, not a mocked launcher. + // These Node API guards cover MCPVault's process, not an OS-wide network sandbox. + await writeFile(preload, ` +const fs = require('node:fs'); +const block = () => { + fs.appendFileSync(${JSON.stringify(attemptsPath)}, 'blocked\\n'); + throw new Error('Network connections are forbidden in the Electron MCPVault test'); +}; +for (const name of ['node:http', 'node:https']) { + const module = require(name); + module.request = block; + module.get = block; +} +const net = require('node:net'); +net.connect = net.createConnection = net.Socket.prototype.connect = block; +require('node:tls').connect = block; +globalThis.fetch = block; +require('node:module').syncBuiltinESMExports(); +fs.appendFileSync(${JSON.stringify(auditPath)}, JSON.stringify({ + pid: process.pid, + executable: fs.realpathSync(process.execPath), + electron: process.versions.electron, + node: process.versions.node, + runAsNode: process.env.ELECTRON_RUN_AS_NODE, + path: process.env.PATH, + cwd: process.cwd(), + entry: process.argv[1] +}) + '\\n'); +`) + const service = new ObsidianService({ + appPath: join(root, 'app.asar'), + homePath: root, + environment: { ...baseEnvironment, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }, + launchEnvironmentProvider: () => prepared.environment + }) + const settings = { vaultPath } + const connection = await service.testConnection(settings) + expect(connection).toEqual({ vaults: await service.listVaults(settings), toolCount: 18 }) + expect(connection.vaults).toHaveLength(1) + expect(connection.vaults[0]?.path).toBe(vaultPath) + expect(await readdir(vaultPath)).toEqual([]) + + const content = '# Electron integration\nWritten through the real MCP stdio transport.' + const frontmatter = { title: 'Temporary Electron note', tags: ['integration'] } + const written = await service.callTool(settings, { + name: 'write_note', arguments: { path: 'electron.md', content, frontmatter } + }) + expect(written.isError, JSON.stringify(written)).not.toBe(true) + const read = await service.callTool(settings, { + name: 'read_note', arguments: { path: 'electron.md' } + }) + expect(read.isError, JSON.stringify(read)).not.toBe(true) + const note = JSON.parse(read.content.map((item) => item.type === 'text' ? item.text : '').join('\n')) + expect(note.content.trim()).toBe(content) + expect(note.fm).toEqual(frontmatter) + expect(await readFile(join(vaultPath, 'electron.md'), 'utf8')).toContain(content) + + const children = (await readFile(auditPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(children).toHaveLength(3) + for (const child of children) { + expect(child).toMatchObject({ + executable: electronExecutablePath, + electron: expect.stringMatching(/^\d+\.\d+\.\d+/u), + node: prepared.diagnostics.tools.node.version, + runAsNode: '1', + path: prepared.environment.PATH, + cwd: vaultPath, + entry: join(output, 'node_modules', '@bitbonsai', 'mcpvault', 'dist', 'server.js') + }) + await expect.poll(() => { + try { + process.kill(child.pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } + }, { timeout: 5_000 }).toBe(false) + } + expect(await readFile(attemptsPath, 'utf8')).toBe('') + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' }) + } +}, 60_000) diff --git a/src/main/obsidian/obsidian-service.test.ts b/src/main/obsidian/obsidian-service.test.ts new file mode 100644 index 00000000..5c5a0465 --- /dev/null +++ b/src/main/obsidian/obsidian-service.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment node +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { packageObsidianMcpVault } from './package-mcpvault' +import { ObsidianService } from './obsidian-service' + +let root: string +let appPath: string +let vaultPath: string +let bin: string +let service: ObsidianService +const electronExecutable = createRequire(import.meta.url)('electron') as string +const provider = vi.fn(() => ({ PATH: process.platform === 'win32' + ? `${bin}${delimiter}${join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')}` + : `${bin}:/usr/bin:/bin` })) + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-')) + appPath = join(root, 'app.asar.unpacked') + vaultPath = join(root, 'Test Vault') + bin = join(root, 'managed-bin') + await mkdir(vaultPath) + await mkdir(bin) + const probe = join(root, 'child-pid.cjs') + await writeFile(probe, `if (!process.versions.electron) throw new Error('Expected Electron managed Node'); require('node:fs').appendFileSync(${JSON.stringify(join(root, 'pids'))}, process.pid + '\\n')`) + // Run the copied package through real Electron rather than the test runner's Node. + await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'), + process.platform === 'win32' + ? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${electronExecutable}" --require "${probe}" %*\r\n` + : `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${electronExecutable.replaceAll("'", "'\\''")}' --require '${probe.replaceAll("'", "'\\''")}' "$@"\n`, + { mode: 0o755 }) + await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault')) + service = new ObsidianService({ appPath: join(root, 'app.asar'), launchEnvironmentProvider: provider }) +}, 60_000) + +afterEach(() => vi.restoreAllMocks()) +afterAll(async () => { await rm(root, { recursive: true, force: true }) }) + +describe('Obsidian vault discovery', () => { + it.each(['win32', 'darwin', 'linux'] as const)('discovers all registered vaults on %s, including closed ones', async (platform) => { + const home = join(root, platform) + const config = platform === 'win32' ? join(home, 'roaming') + : platform === 'darwin' ? join(home, 'Library', 'Application Support') : join(home, 'xdg') + await mkdir(join(config, 'obsidian'), { recursive: true }) + await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: { + first: { path: vaultPath, open: true }, second: { path: bin, open: false } + } })) + const discovery = new ObsidianService({ appPath, launchEnvironmentProvider: provider, platform, homePath: home, + environment: { APPDATA: config, XDG_CONFIG_HOME: config } }) + expect(await discovery.listVaults({})).toEqual([ + { id: 'first', name: 'Test Vault', path: vaultPath }, { id: 'second', name: 'managed-bin', path: bin } + ]) + await expect(discovery.callTool({}, { name: 'list_directory', arguments: {} })).rejects.toThrow('vaultId is required') + await expect(discovery.callTool({}, { vaultId: 'missing', name: 'list_directory', arguments: {} })).rejects.toThrow('Unknown Obsidian vaultId') + const probe = await discovery.testConnection({}) + expect(probe.vaults).toHaveLength(2) + expect(probe.toolCount).toBeGreaterThan(15) + const result = await discovery.callTool({}, { vaultId: 'first', name: 'list_directory', arguments: {} }) + expect(result.isError).not.toBe(true) + }) + + it('bypasses registry for an explicit folder and returns stable path IDs', async () => { + const explicit = new ObsidianService({ appPath, launchEnvironmentProvider: provider, homePath: join(root, 'missing'), environment: {} }) + const first = await explicit.listVaults({ vaultPath }) + expect(await explicit.listVaults({ vaultPath: join(vaultPath, '.') })).toEqual(first) + await expect(explicit.listVaults({})).rejects.toThrow('Cannot read Obsidian vault registry') + await expect(explicit.listVaults({ vaultPath: join(root, 'absent') })).rejects.toThrow('missing or inaccessible') + await expect(explicit.listVaults({ vaultPath: 'relative' })).rejects.toThrow('must be absolute') + const file = join(root, 'not-a-folder') + await writeFile(file, '') + await expect(explicit.listVaults({ vaultPath: file })).rejects.toThrow('not a folder') + }) + + it('reports empty and invalid registries explicitly', async () => { + const config = join(root, 'empty-config') + await mkdir(join(config, 'obsidian'), { recursive: true }) + const registry = join(config, 'obsidian', 'obsidian.json') + const discovery = new ObsidianService({ appPath, launchEnvironmentProvider: provider, platform: 'linux', environment: { XDG_CONFIG_HOME: config } }) + await writeFile(registry, '{}') + await expect(discovery.listVaults({ vaultPath: '' })).rejects.toThrow('No Obsidian vaults') + await writeFile(registry, '{"vaults":{"bad":{}}}') + await expect(discovery.listVaults({})).rejects.toThrow('Invalid Obsidian vault registry entry: bad') + }) +}) + +describe('offline packaged MCPVault', () => { + it('ships exact upstream version, licenses, and native trash assets outside asar', async () => { + const modules = join(appPath, 'out', 'main', 'obsidian-mcpvault', 'node_modules') + expect(JSON.parse(await readFile(join(modules, '@bitbonsai', 'mcpvault', 'package.json'), 'utf8')).version).toBe('0.16.0') + expect(await readFile(join(modules, '@bitbonsai', 'mcpvault', 'LICENSE'), 'utf8')).toContain('MIT') + expect((await stat(join(modules, 'trash', 'lib', 'windows-trash.exe'))).size).toBeGreaterThan(0) + expect((await stat(join(modules, 'trash', 'lib', 'macos-trash'))).size).toBeGreaterThan(0) + }) + + it('lists every upstream tool and probes without modifying the vault', async () => { + const before = await readdir(vaultPath) + const tools = await service.listTools({ vaultPath }) + expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([ + 'read_note', 'write_note', 'patch_note', 'delete_note', 'move_note', 'move_file', + 'update_frontmatter', 'manage_tags', 'search_notes', 'list_directory' + ])) + const source = await readFile(resolve('node_modules/@bitbonsai/mcpvault/dist/src/createServer.js'), 'utf8') + const names = [...source.matchAll(/name: "([a-z_]+)"/gu)].map((match) => match[1]) + expect(tools.map((tool) => tool.name)).toEqual(names) + expect(await service.testConnection({ vaultPath })).toEqual({ vaults: await service.listVaults({ vaultPath }), toolCount: tools.length }) + expect(await readdir(vaultPath)).toEqual(before) + expect(provider).toHaveBeenCalled() + }) + + it('writes, reads, patches, updates frontmatter and lists notes using only the copied package', async () => { + const call = async (name: string, args: Record) => { + const result = await service.callTool({ vaultPath }, { name, arguments: args }) + expect(result.isError, JSON.stringify(result)).not.toBe(true) + return result.content.map((item) => item.type === 'text' ? item.text : '').join('\n') + } + await call('write_note', { path: 'smoke.md', content: '# Original\nBody', frontmatter: { title: 'Smoke', tags: ['test'] } }) + expect(await call('read_note', { path: 'smoke.md' })).toContain('# Original') + await call('patch_note', { path: 'smoke.md', oldString: 'Original', newString: 'Patched' }) + await call('update_frontmatter', { path: 'smoke.md', frontmatter: { status: 'verified' } }) + const note = JSON.parse(await call('read_note', { path: 'smoke.md' })) + expect(note.content).toContain('# Patched') + expect(note.fm).toEqual({ title: 'Smoke', tags: ['test'], status: 'verified' }) + expect(await call('list_directory', {})).toContain('smoke.md') + expect(await readFile(join(vaultPath, 'smoke.md'), 'utf8')).toContain('status: verified') + await call('delete_note', { path: 'smoke.md', confirmPath: 'smoke.md', trashMode: 'local' }) + }) + + it('does not launch for an already cancelled call', async () => { + const start = vi.spyOn(StdioClientTransport.prototype, 'start') + await expect(service.callTool({ vaultPath }, { name: 'list_directory', arguments: {} }, AbortSignal.abort())).rejects.toThrow() + expect(start).not.toHaveBeenCalled() + }) + + it('closes the actual child when cancelled during initialization or a tool request', async () => { + for (const method of ['initialize', 'tools/call']) { + const controller = new AbortController() + const send = StdioClientTransport.prototype.send + const transports = new Set() + const spy = vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) { + transports.add(this) + const result = send.call(this, message) + if ('method' in message && message.method === method) controller.abort(new Error('Cancelled test call')) + return result + }) + await expect(service.callTool({ vaultPath }, { name: 'list_directory', arguments: {} }, controller.signal)).rejects.toThrow('Cancelled test call') + expect(transports.size).toBe(1) + for (const transport of transports) expect(transport.pid).toBeNull() + spy.mockRestore() + } + const pids = (await readFile(join(root, 'pids'), 'utf8')).trim().split('\n').map(Number) + await expect.poll(() => pids.filter((pid) => { + try { process.kill(pid, 0); return true } catch { return false } + })).toEqual([]) + }) +}) diff --git a/src/main/obsidian/obsidian-service.ts b/src/main/obsidian/obsidian-service.ts new file mode 100644 index 00000000..37c9b9cf --- /dev/null +++ b/src/main/obsidian/obsidian-service.ts @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, isAbsolute, join, resolve } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js' +import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment' +import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' + +export type ObsidianSettings = { vaultPath?: string } +export type ObsidianVault = { id: string; name: string; path: string } +export type ObsidianServiceOptions = { + /** Electron app.getAppPath(), or the project root in development. */ + appPath: string + launchEnvironmentProvider: LaunchEnvironmentProvider + homePath?: string + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv +} + +export class ObsidianService { + constructor(private readonly options: ObsidianServiceOptions) {} + + async listVaults(settings: ObsidianSettings): Promise { + const platform = this.options.platform ?? process.platform + const vault = async (path: string, id?: string): Promise => { + if (!isAbsolute(path)) throw new Error(`Obsidian vault path must be absolute: ${path}`) + const normalized = resolve(path) + let directory: boolean + try { + directory = (await stat(normalized)).isDirectory() + } catch (cause) { + throw new Error(`Obsidian vault folder is missing or inaccessible: ${normalized}`, { cause }) + } + if (!directory) throw new Error(`Obsidian vault path is not a folder: ${normalized}`) + return { + id: id ?? createHash('sha256').update(platform === 'win32' ? normalized.toLowerCase() : normalized).digest('hex'), + name: basename(normalized) || normalized, + path: normalized + } + } + if (settings.vaultPath?.trim()) return [await vault(settings.vaultPath)] + + const home = this.options.homePath ?? homedir() + const env = this.options.environment ?? process.env + const configRoot = platform === 'win32' + ? env.APPDATA || join(home, 'AppData', 'Roaming') + : platform === 'darwin' + ? join(home, 'Library', 'Application Support') + : env.XDG_CONFIG_HOME || join(home, '.config') + const configPath = join(configRoot, 'obsidian', 'obsidian.json') + let config: { vaults?: Record } + try { + config = JSON.parse(await readFile(configPath, 'utf8')) + } catch (cause) { + throw new Error(`Cannot read Obsidian vault registry: ${configPath}. Open a vault in Obsidian or set vaultPath.`, { cause }) + } + const entries = Object.entries(config?.vaults ?? {}) + if (!entries.length) throw new Error('No Obsidian vaults registered. Open a vault in Obsidian or set vaultPath.') + return Promise.all(entries.map(([id, entry]) => { + if (typeof entry?.path !== 'string' || !entry.path) { + throw new Error(`Invalid Obsidian vault registry entry: ${id}`) + } + return vault(entry.path, id) + })) + } + + async testConnection(settings: ObsidianSettings): Promise<{ vaults: ObsidianVault[]; toolCount: number }> { + const vaults = await this.listVaults(settings) + const names = new Set() + for (const vault of vaults) { + const tools = await this.withClient(vault, (client) => this.readTools(client)) + for (const tool of tools) names.add(tool.name) + } + return { vaults, toolCount: names.size } + } + + /** Unmodified upstream schemas; callers can wrap every tool with a vaultId argument. */ + async listTools(settings: ObsidianSettings): Promise { + const [vault] = await this.listVaults(settings) + return this.withClient(vault!, (client) => this.readTools(client)) + } + + async callTool( + settings: ObsidianSettings, + request: { vaultId?: string; name: string; arguments: Record }, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + const vaults = await this.listVaults(settings) + const vault = request.vaultId === undefined + ? vaults.length === 1 ? vaults[0] : undefined + : vaults.find((candidate) => candidate.id === request.vaultId) + if (!vault) { + throw new Error(request.vaultId === undefined + ? 'Multiple Obsidian vaults are available; vaultId is required.' + : `Unknown Obsidian vaultId: ${request.vaultId}`) + } + return this.withClient(vault, async (client) => CallToolResultSchema.parse(await client.callTool( + { name: request.name, arguments: request.arguments }, + CallToolResultSchema, + { signal } + )), signal) + } + + private async readTools(client: Client): Promise { + const tools: Tool[] = [] + let cursor: string | undefined + do { + const page = await client.listTools({ cursor }) + tools.push(...page.tools) + cursor = page.nextCursor + } while (cursor) + return tools + } + + private async withClient(vault: ObsidianVault, action: (client: Client) => Promise, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const appPath = this.options.appPath.replace(/\.asar(?=[\\/]|$)/u, '.asar.unpacked') + const serverPath = join(appPath, 'out', 'main', 'obsidian-mcpvault', 'node_modules', '@bitbonsai', 'mcpvault', 'dist', 'server.js') + await stat(serverPath).catch((cause) => { + throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause }) + }) + const env = applyLaunchEnvironmentPath( + buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env), + this.options.launchEnvironmentProvider + ) + const client = new Client({ name: 'goodbuddy-obsidian', version: '1.0.0' }) + const transport = new StdioClientTransport({ + command: 'node', + args: [serverPath, vault.path], + cwd: vault.path, + env: Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)), + stderr: 'ignore' + }) + // SDK initialization failures also close asynchronously; await that same cleanup. + const closeTransport = transport.close.bind(transport) + let closing: Promise | undefined + transport.close = () => closing ??= closeTransport() + try { + signal?.throwIfAborted() + await client.connect(transport, { signal }) + signal?.throwIfAborted() + return await action(client) + } finally { + // Close the transport even if initialization failed before Client took ownership. + await transport.close() + await client.close() + } + } +} diff --git a/src/main/obsidian/package-mcpvault.ts b/src/main/obsidian/package-mcpvault.ts new file mode 100644 index 00000000..3c7e53f5 --- /dev/null +++ b/src/main/obsidian/package-mcpvault.ts @@ -0,0 +1,41 @@ +import { cp, mkdir, readFile, rm, stat } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' + +/** Keep upstream module-relative assets and licenses, including trash's native helpers. */ +export async function packageObsidianMcpVault(projectRoot: string, output: string): Promise { + const modules = resolve(projectRoot, 'node_modules') + const visited = new Set() + await rm(output, { recursive: true, force: true }) + await mkdir(output, { recursive: true }) + + async function copyDependency(name: string, from: string): Promise { + let directory = from + let source: string + for (;;) { + source = join(directory, 'node_modules', name) + if (await stat(join(source, 'package.json')).then(() => true, () => false)) break + const parent = dirname(directory) + if (parent === directory) throw new Error(`Missing MCPVault dependency: ${name}`) + directory = parent + } + if (visited.has(source)) return + visited.add(source) + const target = join(output, 'node_modules', relative(modules, source)) + const manifest = JSON.parse(await readFile(join(source, 'package.json'), 'utf8')) as { + version: string + dependencies?: Record + } + if (name === '@bitbonsai/mcpvault' && manifest.version !== '0.16.0') { + throw new Error(`Expected MCPVault 0.16.0, found ${manifest.version}`) + } + await cp(source, target, { + recursive: true, + filter: (path) => path === source || !relative(source, path).split(/[\\/]/u).includes('node_modules') + }) + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + await copyDependency(dependency, source) + } + } + + await copyDependency('@bitbonsai/mcpvault', projectRoot) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 01fa8846..613ed122 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -53,6 +53,7 @@ import type { CapabilitySnapshot, ComputerCapabilityId, McpServerTestResult, + ObsidianConnectionTestResult, WebSearchTestResult } from '../shared/capability-contracts' import type { @@ -1450,6 +1451,12 @@ const desktopApi: DesktopApi = { serverId, enabled }) as Promise, + updateObsidianSettings: (input) => + ipcRenderer.invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input) as Promise, + testObsidianConnection: (input) => + ipcRenderer.invoke(ipcChannels.capabilitiesTestObsidianConnection, input) as Promise, + selectObsidianVault: () => + ipcRenderer.invoke(ipcChannels.capabilitiesSelectObsidianVault) as Promise, setBuiltinMcpServerAssignments: ( serverId: BuiltinMcpServerId, assignments diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index a55edd8b..1b2d7e54 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -731,39 +731,55 @@ const api: DesktopApi = { getSnapshot: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), importSkill: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), removeSkill: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), setSkillEnabled: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), setSkillAssignments: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), setBuiltinMcpServerEnabled: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), setBuiltinMcpServerAssignments: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), saveMcpServer: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), removeMcpServer: vi.fn(async () => ({ skills: [], mcpServers: [], + obsidian: { vaultPath: "" }, })), + updateObsidianSettings: vi.fn(async (obsidian) => ({ + skills: [], + mcpServers: [], + obsidian, + })), + testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })), + selectObsidianVault: vi.fn(async () => null), testMcpServer: vi.fn(async () => ({ dynamicToolsSupported: false, toolCount: 0, diff --git a/src/renderer/src/McpSettingsSection.test.tsx b/src/renderer/src/McpSettingsSection.test.tsx index 947c06fa..b2e876f1 100644 --- a/src/renderer/src/McpSettingsSection.test.tsx +++ b/src/renderer/src/McpSettingsSection.test.tsx @@ -5,6 +5,149 @@ import i18n from './i18n' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +it('defaults Obsidian off and tests draft scope without saving or enabling it', async () => { + const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '' } } + const getSnapshot = vi.fn().mockResolvedValue(snapshot) + const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input })) + const testObsidianConnection = vi.fn().mockResolvedValue({ + vaults: [{ id: 'work', name: 'Work', path: '/notes/work' }], toolCount: 24 + }) + const selectObsidianVault = vi.fn().mockResolvedValue('/notes/work') + const setBuiltinMcpServerEnabled = vi.fn() + vi.stubGlobal('goodbuddy', { capabilities: { + getSnapshot, updateObsidianSettings, testObsidianConnection, + selectObsidianVault, setBuiltinMcpServerEnabled + } }) + render() + const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') }) + await waitFor(() => expect(testButton).toBeEnabled()) + expect(screen.getByRole('switch', { name: i18n.t('integrations:mcp.builtin.enableAriaLabel', { name: 'Obsidian' }) })).not.toBeChecked() + fireEvent.click(testButton) + await waitFor(() => { + expect(testObsidianConnection).toHaveBeenCalledWith({ vaultPath: '' }) + expect(screen.getByText('/notes/work')).toBeVisible() + expect(testButton).toBeEnabled() + }) + expect(updateObsidianSettings).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })) + await waitFor(() => { + expect(screen.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).toHaveValue('/notes/work') + expect(testButton).toBeEnabled() + }) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })) + await waitFor(() => { + expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: '/notes/work' }) + expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: '/notes/work' }))).toBeVisible() + expect(testButton).toBeEnabled() + }) + fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } }) + fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })) + await waitFor(() => { + expect(updateObsidianSettings).toHaveBeenLastCalledWith({ vaultPath: '' }) + expect(testButton).toBeEnabled() + }) + expect(setBuiltinMcpServerEnabled).not.toHaveBeenCalled() +}) + +it.each(['loaded', 'saved'] as const)('keeps a %s specified vault selected when its draft path is cleared', async (source) => { + const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } } + const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input })) + const testObsidianConnection = vi.fn().mockResolvedValue({ + vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24 + }) + vi.stubGlobal('goodbuddy', { capabilities: { + getSnapshot: vi.fn().mockResolvedValue(snapshot), + updateObsidianSettings, testObsidianConnection + } }) + render() + const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') }) + const scope = screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }) + const saveButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }) + const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') }) + const savedPath = source === 'saved' ? '/notes/saved' : '/notes/original' + if (source === 'saved') { + fireEvent.change(input, { target: { value: savedPath } }) + fireEvent.click(saveButton) + await waitFor(() => { + expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: savedPath }) + expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible() + expect(saveButton).toBeEnabled() + }) + updateObsidianSettings.mockClear() + } + fireEvent.click(testButton) + await waitFor(() => { + expect(screen.getByText('/notes/discovered')).toBeVisible() + expect(testButton).toBeEnabled() + }) + testObsidianConnection.mockClear() + fireEvent.change(input, { target: { value: '' } }) + expect(scope).toHaveValue('folder') + expect(input).toBeVisible() + expect(input).toHaveValue('') + expect(screen.queryByRole('status')).not.toBeInTheDocument() + for (const button of [saveButton, testButton]) { + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(i18n.t('integrations:mcp.obsidian.folderRequired')) + expect(scope).toHaveValue('folder') + expect(input).toBeVisible() + expect(input).toHaveValue('') + expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible() + expect(button).toBeEnabled() + }) + } + expect(updateObsidianSettings).not.toHaveBeenCalled() + expect(testObsidianConnection).not.toHaveBeenCalled() +}) + +it('clears Obsidian connection results when changing scope', async () => { + const testObsidianConnection = vi.fn().mockResolvedValue({ + vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24 + }) + vi.stubGlobal('goodbuddy', { capabilities: { + getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/work' } }), + testObsidianConnection + } }) + render() + const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') }) + await waitFor(() => expect(testButton).toBeEnabled()) + fireEvent.click(testButton) + await waitFor(() => { + expect(screen.getByText('/notes/discovered')).toBeVisible() + expect(testButton).toBeEnabled() + }) + fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } }) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).not.toBeInTheDocument() +}) + +it('preserves the Obsidian draft after failures and folder-picker cancellation', async () => { + const selectObsidianVault = vi.fn().mockResolvedValue(null) + const updateObsidianSettings = vi.fn().mockRejectedValue(new Error('Disk unavailable')) + vi.stubGlobal('goodbuddy', { capabilities: { + getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } }), + selectObsidianVault, updateObsidianSettings + } }) + render() + const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') }) + fireEvent.change(input, { target: { value: '/notes/draft' } }) + const selectButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') }) + fireEvent.click(selectButton) + await waitFor(() => { + expect(selectObsidianVault).toHaveBeenCalledOnce() + expect(selectButton).toBeEnabled() + expect(input).toHaveValue('/notes/draft') + }) + fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })) + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Disk unavailable') + expect(input).toHaveValue('/notes/draft') + expect(selectButton).toBeEnabled() + }) +}) + it('removes the redundant MCP title and places the custom action in content', async () => { const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] }) vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } }) diff --git a/src/renderer/src/McpSettingsSection.tsx b/src/renderer/src/McpSettingsSection.tsx index e083c259..34a7e8a2 100644 --- a/src/renderer/src/McpSettingsSection.tsx +++ b/src/renderer/src/McpSettingsSection.tsx @@ -12,7 +12,7 @@ import { Wrench, X } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { builtinMcpServers } from '../../shared/builtin-mcp-servers' @@ -27,6 +27,7 @@ import type { McpServerSummary, McpServerTestResult, McpTransport, + ObsidianConnectionTestResult, RuntimeTarget, WebSearchTestResult } from '../../shared/capability-contracts' @@ -119,6 +120,12 @@ export function McpSettingsSection({ disabled: t('mcp.diagnosticStatuses.disabled') } const [snapshot, setSnapshot] = useState() + const [obsidianPath, setObsidianPath] = useState() + const [obsidianResult, setObsidianResult] = useState() + const [obsidianError, setObsidianError] = useState() + const [obsidianSpecified, setObsidianSpecified] = useState() + const vaultPath = obsidianPath ?? snapshot?.obsidian?.vaultPath ?? '' + const specifiedVault = obsidianSpecified ?? Boolean(snapshot?.obsidian?.vaultPath) const [editor, setEditor] = useState() const [busy, setBusy] = useState() const [error, setError] = useState() @@ -310,6 +317,40 @@ export function McpSettingsSection({ } } + const configureObsidian = async (action: 'save' | 'test' | 'select'): Promise => { + setBusy(`obsidian:${action}`) + setObsidianError(undefined) + try { + const api = window.goodbuddy.capabilities + if (action === 'select') { + const path = await api.selectObsidianVault() + if (path !== null) { + setObsidianPath(path) + setObsidianSpecified(true) + setObsidianResult(undefined) + } + } else { + const input = { vaultPath: specifiedVault ? vaultPath.trim() : '' } + if (specifiedVault && !input.vaultPath) { + throw new Error(t('mcp.obsidian.folderRequired')) + } + if (action === 'save') { + const next = await api.updateObsidianSettings(input) + setSnapshot(next) + setObsidianPath(undefined) + setObsidianSpecified(undefined) + } else { + setObsidianResult(undefined) + setObsidianResult(await api.testObsidianConnection(input)) + } + } + } catch (reason) { + setObsidianError(reason instanceof Error ? reason.message : t('mcp.errors.operation')) + } finally { + setBusy(undefined) + } + } + const updateAssignment = ( target: RuntimeTarget, checked: boolean @@ -373,13 +414,13 @@ export function McpSettingsSection({ trapTabFocus(event, editorDialogRef.current) } - const computerCapabilities = snapshot?.computerCapabilities ?? [] - const desktopCapabilities = computerCapabilities.filter( + const computerCapabilities = useMemo(() => snapshot?.computerCapabilities ?? [], [snapshot?.computerCapabilities]) + const desktopCapabilities = useMemo(() => computerCapabilities.filter( (capability) => capability.id !== 'host-browser-control' - ) - const builtinMcpStates = new Map( + ), [computerCapabilities]) + const builtinMcpStates = useMemo(() => new Map( snapshot?.builtinMcpServers?.map((server) => [server.id, server]) - ) + ), [snapshot?.builtinMcpServers]) const webSearch = snapshot?.webSearch ?? { provider: 'exa' as const, enabled: true, @@ -614,7 +655,7 @@ export function McpSettingsSection({ const panelId = `mcp-server-tools-${server.id}` const state = builtinMcpStates.get(server.id) ?? { id: server.id, - enabled: server.id !== 'builtin-browser', + enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian', assignments: [...server.supportedAssignments] as CapabilityAssignments } @@ -649,6 +690,8 @@ export function McpSettingsSection({ ? t('mcp.browser.unsupported') : !featureAvailable ? t('mcp.builtin.serverSummaryDisabled') + : server.id === 'obsidian' + ? t('mcp.obsidian.access') : server.id === 'builtin-browser' ? t('mcp.builtin.serverSummaryExecuteOnly') : server.access === 'mixed' @@ -681,7 +724,63 @@ export function McpSettingsSection({ : t('mcp.builtin.disabled')} -

{server.description}

+

{server.id === 'obsidian' ? t('mcp.obsidian.description') : server.description}

+ {server.id === 'obsidian' && ( +
+ + {specifiedVault && ( + + )} +

{t('mcp.obsidian.scopeHelp')}

+

{t('mcp.obsidian.savedScope', { path: snapshot?.obsidian?.vaultPath || t('mcp.obsidian.all') })}

+
+ + + +
+ {obsidianError && } + {obsidianResult && ( +
+ {t('mcp.obsidian.result', { vaults: obsidianResult.vaults.length, tools: obsidianResult.toolCount })} + {obsidianResult.vaults.length === 0 &&

{t('mcp.obsidian.empty')}

} + {obsidianResult.vaults.map((vault) =>

{vault.name}: {vault.path}

)} +
+ )} +
+ )} {!featureAvailable && (