diff --git a/README.md b/README.md index aeeccee..0cfd8a6 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,10 @@ RC2 的 `credentials/reference-updated` 事件会刷新设置卡片中的“已 ## 组合覆盖 -插件通过 `cordis.patch.yml`: +插件通过 `cordis.patch.yml` 插入 `search-mcp` 行并提高 `tool-web` 上限;**不**在 patch 里写 `web.searchProvider`(Desktop 0.2 会死锁)。路由切换由 host `apply` 在注册后 **live-pin** `ctx.web.searchProviderId = search-mcp` 完成: -- 注册 `search-mcp` provider。 -- 设置 `web.searchProvider: search-mcp`。 -- 禁用 `web-search-deepseek`。 -- 保持 `web_fetch` 关闭。 +- 注册 `search-mcp` provider,并热切换活跃搜索提供商。 +- 保持 `web_fetch` 关闭(`tool-web.fetch: false`)。 - 请求 `tool-web.searchMaxResults: 50` 和 `searchMaxQueries: 4`。 RC2 的 standard、code、cordis agent preset 各自包含 `tool-web` 行,并且都省略了 `searchMaxResults` 和 `searchMaxQueries`,因此实际采用 `dsh-tool-web` 默认值 8 和 4。agent-scoped 工具会遮蔽根层同名工具,所以根层 patch 中的 50 条请求不会提高这些 shipped preset 的实际上限。验证结果上限时必须检查 session 使用的 preset,不能只依据根层 `--dump-config`。 @@ -153,11 +151,12 @@ dsh --profile web --dump-config | 预期至少包括: -- `web.searchProvider: search-mcp` -- `web-search-deepseek.disabled: true` +- 组合中出现 `search-mcp` 行(bailian / `DASHSCOPE_API_KEY`) - `tool-web.disabled: false` - `fetch: false` +说明:Desktop 0.2 下 `--dump-config` **不会**出现 `web.searchProvider: search-mcp`(避免启动死锁);运行时由 host live-pin。验收应以错误密钥时出现 search-mcp/百炼错误、而非 DeepSeek HTTP 401 为准。 + 实际 agent preset 的结果数和多查询上限应在隔离 profile/session 中单独验证。 ## 故障排查 diff --git a/cordis.patch.yml b/cordis.patch.yml index 1b47ce0..649aecd 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -5,9 +5,9 @@ # load-order deadlock (web waits for search-mcp, search-mcp waits for web) # and freezes the UI on "Loading plugins…". # -# The host `apply` registers the provider and then soft-switches the web -# row via settings when available. Removing this package restores the -# previous composition (no searchProvider pin left behind by this file). +# The host `apply` registers the provider and then live-pins +# `ctx.web.searchProviderId = search-mcp` (no settings mutate / no patch pin). +# Removing this package restores the previous composition. - insert: - id: search-mcp diff --git a/lib/index.js b/lib/index.js index 9af7853..98f7211 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,59 +1,75 @@ /** * dsh-search-mcp — replace dsh's built-in web search with search MCP servers. * - * Adapted for DeepSeek Harness 0.1.2+: settings use - * `ctx.settings.installSection` (the old free-function - * `installSettingsSection` from 0.1.1-rc.2 no longer exists). + * Registers a `ctx.web` search provider under id `search-mcp`, then **live-pins** + * the underlying WebRuntime's `searchProviderId` so `web_search` stops using + * `deepseek-official`. * - * A Cordis plugin that - * - registers a `ctx.web` search provider under the id `search-mcp`, and - * - installs a Settings section (`search-mcp`) where the user manages the - * search MCP server list (kind, endpoint/command, API key or key env - * reference, tool name) plus `defaultServer` / `maxResults` / - * `searchTimeoutMs` from the web Settings → Plugins page. + * Why not cordis.patch.yml? + * Pinning `web.searchProvider: search-mcp` (or disabling deepseek) in the + * bundle patch deadlocks Desktop 0.2 boot: `web` waits for `search-mcp` while + * this plugin `inject: ['web']`. Live-pin after `registerSearchProvider` avoids + * that cycle. Do not persist the pin into settings.yaml either — a restart + * would reintroduce the same deadlock. * - * The package's `cordis.patch.yml` (bundle layer) switches - * `web.searchProvider` to `search-mcp` and disables the built-in - * `web-search-deepseek` provider, so while this plugin is enabled the - * built-in search is unavailable and every `web_search` call runs through - * the configured MCP server(s). Removing the package restores the built-in. + * Why unwrap cordis.original? + * `ctx.web` is a traceable Proxy. Assigning `ctx.web.searchProviderId = …` + * does not update the field `search()` reads; pin the unwrapped instance. */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import z from '@deepseek-ai/schemastery'; import { credentialRef } from '@deepseek-ai/dsh-credentials'; import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'; -import { SearchMCPProvider } from './provider.js'; +import { SEARCH_MCP_PROVIDER_ID, SearchMCPProvider } from './provider.js'; /** Cordis plugin name used by loader diagnostics. */ export const name = 'search-mcp'; +/** Module-load breadcrumb — proves Desktop resolved this build (even if apply never runs). */ +try { + const pkgVersion = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'), + ).version; + writeFileSync( + join(process.env.USERPROFILE || process.env.HOME || '', '.dsh', 'search-mcp-loaded.json'), + `${JSON.stringify({ t: new Date().toISOString(), version: pkgVersion, name }, null, 2)}\n`, + ); +} catch { + /* ignore */ +} + /** The web seam this provider registers into. */ export const inject = ['web']; -/** - * DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the - * settings UI. Zero volatile fields → the whole entry is silently filtered. - * Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path). - */ -function vol(schema) { - return typeof schema?.volatile === 'function' ? schema.volatile() : schema; -} +/** Unwrap cordis service proxy → concrete Service instance. */ +const CORDIS_ORIGINAL = Symbol.for('cordis.original'); +/** + * Config schema — no `.volatile()` anywhere. + * + * Desktop 0.2 cordis rejects nested volatiles (`servers` + `servers.*.id`) with + * ValidationError and leaves the fiber inactive. The settings UI is a custom + * `settings.section` (client.browser.js), so Config does not need volatile + * projection for the form to appear. + */ const serverSchema = z.object({ - id: vol(z.string()), - kind: vol(z.string().default('custom')), - transport: vol(z.string().default('http')), - url: vol(z.string().default('')), - command: vol(z.string().default('')), - args: vol(z.array(z.string()).default([])), - apiKey: vol(z.string().role('secret')), - apiKeyEnv: vol(z.string().role('credential-ref').default('')), - authStyle: vol(z.string().default('')), - authParam: vol(z.string().default('')), - authPrefix: vol(z.string().default('')), - toolName: vol(z.string().default('')), + id: z.string(), + kind: z.string().default('custom'), + transport: z.string().default('http'), + url: z.string().default(''), + command: z.string().default(''), + args: z.array(z.string()).default([]), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().role('credential-ref').default(''), + authStyle: z.string().default(''), + authParam: z.string().default(''), + authPrefix: z.string().default(''), + toolName: z.string().default(''), // Note: this schemastery fork has no `.optional()`; object fields are // optional unless `.required()` is applied, so absence is already allowed. - maxResults: vol(z.number().step(1).min(1).max(50)), + maxResults: z.number().step(1).min(1).max(50), }); const DEFAULT_BAILIAN_SERVER = { @@ -63,34 +79,128 @@ const DEFAULT_BAILIAN_SERVER = { }; export const Config = z.object({ - defaultServer: vol(z.string().default('bailian')), - maxResults: vol(z.number().step(1).min(1).max(50).default(8)), - searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)), - servers: vol(z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER])), + defaultServer: z.string().default('bailian'), + maxResults: z.number().step(1).min(1).max(50).default(8), + searchTimeoutMs: z.number().step(1).min(1000).default(30000), + servers: z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER]), }); /** Settings namespace owning this plugin's section (Settings → Plugins card). */ export const SEARCH_MCP_SETTINGS_NAMESPACE = 'search-mcp'; -/** Normalize settings.describe() across DSH generations. */ -function describeRows(describe) { - if (typeof describe !== 'function') return []; - try { - const raw = describe(); - if (Array.isArray(raw)) return raw; - if (raw && typeof raw === 'object' && Array.isArray(raw.namespaces)) return raw.namespaces; - } catch { - // describe can throw while the provider is settling - } - return []; +/** Concrete WebRuntime behind `ctx.web` (cordis traceable proxy). */ +function unwrapWeb(web) { + if (!web || typeof web !== 'object') return null; + const raw = web[CORDIS_ORIGINAL]; + return raw && typeof raw === 'object' ? raw : web; } -/** Register the search provider. Settings UI is owned by the web client. */ +/** Match WebRuntime.capSources so direct dispatch keeps the same contract. */ +function capSources(result, maxResults) { + if (maxResults === undefined || !Array.isArray(result?.sources) || result.sources.length <= maxResults) { + return result; + } + return { ...result, sources: result.sources.slice(0, maxResults), truncated: true }; +} + +/** + * Hot-takeover for the life of this fiber: + * - pin + wrap on apply (enable / boot) + * - restore previous provider + unwrap on dispose (disable) + * + * No Desktop restart needed for enable/disable — only for loading a new + * package build into an already-running process. + */ +function takeOverWebSearch(ctx, provider) { + const web = unwrapWeb(ctx.web); + if (!web || typeof web.search !== 'function' || !provider) return false; + + const previousId = web.searchProviderId; + // Leftover pin from a crashed unload → fall back to Desktop default. + const restoreId = + previousId === SEARCH_MCP_PROVIDER_ID || previousId === undefined + ? 'deepseek-official' + : previousId; + + const previousSearch = web.search.__searchMcpWrapped ? web.search.__searchMcpOriginal : web.search; + + try { + web.searchProviderId = SEARCH_MCP_PROVIDER_ID; + } catch (error) { + ctx.logger?.warn?.('search-mcp: failed to assign web.searchProviderId: %s', error); + return false; + } + + async function searchMcpPinned(request, signal) { + web.searchProviderId = SEARCH_MCP_PROVIDER_ID; + if (typeof provider.available === 'function' && provider.available()) { + const result = await provider.search(request, signal); + return capSources(result, request?.maxResults); + } + return previousSearch.call(web, request, signal); + } + searchMcpPinned.__searchMcpWrapped = true; + searchMcpPinned.__searchMcpOriginal = previousSearch; + web.search = searchMcpPinned; + + // Fiber unload (plugin disable) → hand search back to the built-in path. + ctx.effect(() => () => { + if (web.search === searchMcpPinned) { + web.search = previousSearch; + } + if (web.searchProviderId === SEARCH_MCP_PROVIDER_ID) { + web.searchProviderId = restoreId; + } + ctx.logger?.info?.( + 'search-mcp: released web search (restored searchProviderId → %s)', + restoreId === undefined ? '(unset)' : restoreId, + ); + }); + + ctx.logger?.info?.( + 'search-mcp: took over web.search (%s → %s); disable plugin to release', + previousId === undefined ? '(unset)' : previousId, + SEARCH_MCP_PROVIDER_ID, + ); + return true; +} + +/** Best-effort runtime breadcrumb for Desktop diagnosis (~/.dsh/search-mcp-runtime.json). */ +async function writeRuntimeBreadcrumb(payload) { + try { + const { writeFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const home = process.env.USERPROFILE || process.env.HOME || ''; + if (!home) return; + writeFileSync( + join(home, '.dsh', 'search-mcp-runtime.json'), + `${JSON.stringify({ t: new Date().toISOString(), ...payload }, null, 2)}\n`, + ); + } catch { + /* ignore */ + } +} + +/** Register the search provider and take over web_search selection. */ export function apply(ctx, config) { const current = () => config; - // Desktop 0.2: avoid soft-inject(['settings']) + describe poll during bring-up. + // Desktop 0.2: avoid soft-inject(['settings']) during bring-up. // Loader already projects Config from the cordis entry / patch insert. - ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current()))); + const provider = new SearchMCPProvider(() => resolveOptions(ctx, current())); + ctx.web.registerSearchProvider(provider); + // Hot path: enable = take over now; disable = effect disposer restores. + const tookOver = takeOverWebSearch(ctx, provider); + if (!tookOver) { + ctx.logger?.warn?.( + 'search-mcp: could not take over search provider; web_search may still use deepseek-official', + ); + } + void writeRuntimeBreadcrumb({ + event: 'apply', + tookOver, + searchProviderId: unwrapWeb(ctx.web)?.searchProviderId, + servers: (config.servers ?? []).map((s) => ({ id: s.id, kind: s.kind })), + }); } /** diff --git a/lib/provider.js b/lib/provider.js index 3dc22b4..1b4d507 100644 --- a/lib/provider.js +++ b/lib/provider.js @@ -1,10 +1,10 @@ /** * The `search-mcp` web search provider. * - * Registers into `ctx.web` under the stable id `search-mcp`; the profile - * patch switches `web.searchProvider` to this id and disables the built-in - * DeepSeek provider, so the model-facing `web_search` tool executes entirely - * through the configured search MCP server(s). + * Registers into `ctx.web` under the stable id `search-mcp`. The host + * `apply` then live-pins `ctx.web.searchProviderId` to this id so + * model-facing `web_search` runs through the configured search MCP + * server(s) (Desktop 0.2 cannot safely pin via cordis.patch.yml). */ import { WebError } from '@deepseek-ai/dsh-web'; import { resolveServer } from './catalog.js'; diff --git a/package.json b/package.json index 7d1c048..8388928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-search-mcp", - "version": "0.2.32", + "version": "0.2.38", "description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.", "type": "module", "main": "lib/index.js", diff --git a/scripts/_validate-config.mjs b/scripts/_validate-config.mjs new file mode 100644 index 0000000..b296ea4 --- /dev/null +++ b/scripts/_validate-config.mjs @@ -0,0 +1,73 @@ +/** + * Reproduce Desktop Config validation for search-mcp. + * Usage: node scripts/_validate-config.mjs + */ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const nm = join(process.env.USERPROFILE || '', '.dsh', 'profiles', 'node_modules') +const req = createRequire(join(nm, 'package.json')) +const cordisUrl = pathToFileURL(req.resolve('@deepseek-ai/cordis')).href +const { Context, Service } = await import(cordisUrl) +const mod = await import(pathToFileURL(join(pkgRoot, 'lib', 'index.js')).href) + +class WebRuntime extends Service { + searchProviders = new Map() + searchProviderId = 'deepseek-official' + constructor(ctx) { + super(ctx, 'web') + } + registerSearchProvider(p) { + this.searchProviders.set(p.id, p) + return () => this.searchProviders.delete(p.id) + } + async search() { + return { used: this.searchProviderId } + } +} + +function plugin(ctx, config) { + mod.apply(ctx, config) +} +plugin.inject = ['web'] +plugin.Config = mod.Config + +const root = new Context() +try { + await root.plugin(WebRuntime) + await root.plugin(plugin, { + defaultServer: 'bailian', + maxResults: 8, + searchTimeoutMs: 30000, + servers: [{ id: 'bailian', kind: 'bailian', apiKeyEnv: 'DASHSCOPE_API_KEY' }], + }) + const raw = root.web[Symbol.for('cordis.original')] + console.log( + JSON.stringify( + { + ok: true, + faceId: root.web.searchProviderId, + rawId: raw?.searchProviderId, + wrapped: !!raw?.search?.__searchMcpWrapped, + }, + null, + 2, + ), + ) +} catch (e) { + console.log( + JSON.stringify( + { + ok: false, + message: String(e?.message || e), + stack: String(e?.stack || '').slice(0, 1200), + }, + null, + 2, + ), + ) + process.exit(1) +} diff --git a/scripts/_verify-takeover.mjs b/scripts/_verify-takeover.mjs new file mode 100644 index 0000000..2c8d265 --- /dev/null +++ b/scripts/_verify-takeover.mjs @@ -0,0 +1,78 @@ +/** + * Prove unwrap + direct web.search wrap routes off deepseek-official. + * Run: node scripts/_verify-takeover.mjs + */ +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const profilesNm = join(process.env.USERPROFILE || '', '.dsh', 'profiles', 'node_modules') +const require = createRequire(join(profilesNm, 'package.json')) +const { Context, Service } = require('@deepseek-ai/cordis') +const { apply } = await import(pathToFileURL(join(pkgRoot, 'lib', 'index.js')).href) +const ORIGINAL = Symbol.for('cordis.original') + +class WebRuntime extends Service { + searchProviders = new Map() + searchProviderId + constructor(ctx, config = {}) { + super(ctx, 'web') + this.searchProviderId = config.searchProvider ?? 'deepseek-official' + } + registerSearchProvider(p) { + this.searchProviders.set(p.id, p) + return () => this.searchProviders.delete(p.id) + } + async search() { + if (this.searchProviderId === 'deepseek-official') { + return { used: 'deepseek-official', error: 'DeepSeek API error (HTTP 401)' } + } + const p = this.searchProviders.get(this.searchProviderId) + return { used: this.searchProviderId, via: p?.id } + } +} + +const smcp = Object.assign(function (ctx) { + apply(ctx, { + defaultServer: 'bailian', + maxResults: 8, + searchTimeoutMs: 30000, + servers: [{ id: 'bailian', kind: 'bailian', apiKeyEnv: 'DASHSCOPE_API_KEY' }], + }) +}, { inject: ['web'] }) + +const app = new Context() +await app.plugin(WebRuntime, { searchProvider: 'deepseek-official' }) +const fiber = await app.plugin(smcp) + +const raw = app.web[ORIGINAL] +let thrown +try { + await app.web.search({ query: 'venezuela', maxResults: 5 }) +} catch (error) { + thrown = String(error?.message || error) +} + +const enabledOk = + raw.searchProviderId === 'search-mcp' + && raw.search.__searchMcpWrapped === true + && /search-mcp/.test(thrown || '') + +await fiber.dispose() +const afterDisable = await app.web.search({ query: 'venezuela', maxResults: 5 }) +const disabledOk = + raw.searchProviderId === 'deepseek-official' + && !raw.search.__searchMcpWrapped + && afterDisable?.used === 'deepseek-official' + +const ok = enabledOk && disabledOk +console.log(JSON.stringify({ + ok, + enabledOk, + disabledOk, + thrown: thrown?.slice(0, 160), + afterDisable, + rawId: raw.searchProviderId, +}, null, 2)) +process.exit(ok ? 0 : 1) diff --git a/test/compatibility.test.js b/test/compatibility.test.js index f09fb17..b06a8ef 100644 --- a/test/compatibility.test.js +++ b/test/compatibility.test.js @@ -12,7 +12,7 @@ test('package exports resolve and peerDependencies stay open', async () => { assert.equal(pkg.exports['.'], './lib/index.js') assert.equal(pkg.exports['./client'], './lib/client.browser.js') assert.equal(pkg.engines.node, '>=20') - assert.equal(pkg.version, '0.2.32') + assert.equal(pkg.version, '0.2.38') assert.equal(pkg.dsh.client.immediately, false) for (const name of [ @@ -51,13 +51,24 @@ test('browser half registers Settings sidebar + deferred form attach', async () assert.match(client, /legacyKeyBlocked/) }) -test('host registers search provider without settings soft-inject poll', async () => { +test('host registers search provider and hot-takeover without settings mutate', async () => { const host = await read('lib/index.js') - assert.match(host, /function vol\(/) assert.match(host, /registerSearchProvider/) + assert.match(host, /function takeOverWebSearch/) + assert.match(host, /Symbol\.for\(['"]cordis\.original['"]\)/) + assert.match(host, /function unwrapWeb/) + assert.match(host, /provider\.search\(request, signal\)/) + assert.match(host, /web\.searchProviderId\s*=\s*SEARCH_MCP_PROVIDER_ID/) + assert.match(host, /released web search/) + assert.match(host, /takeOverWebSearch\(ctx, provider\)/) + assert.match(host, /ctx\.effect\(\(\) => \(\) => \{/) + // Desktop 0.2: Config must not call .volatile() (array inner is always blocked). + assert.doesNotMatch(host, /[.\w]\.volatile\s*\(/) + assert.doesNotMatch(host, /function vol\(/) const applyBody = host.slice(host.indexOf('export function apply'), host.indexOf('function resolveOptions')) assert.doesNotMatch(applyBody, /ctx\.inject\(\s*\[['"]settings['"]\]/) assert.doesNotMatch(applyBody, /setInterval\(/) + assert.doesNotMatch(applyBody, /settings\.mutate/) }) test('known providers are CDKey-only while custom keeps advanced fields', async () => {