diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index bd19c67..02b92b5 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -16,6 +16,7 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; import { explicitSends, noteExplicitSend, @@ -282,14 +283,61 @@ function takeDenial(agentId: string, callId: unknown): string | undefined { // ─── 运行时导入 DSH 内部函数 ─── let _defineTool: any; +// 插件是 ESM(package.json "type": "module"),ESM 里**没有** require。 +// +// 这里原本写的是裸 `require.resolve(...)` + `require(...)`,于是 try 块必抛 +// `ReferenceError: require is not defined`,被 `catch { return opts; }` 静默吞掉 —— +// defineTool 变成恒等函数,工具的 parameters 以**未编译的裸映射**注册: +// +// parameters: { gateway_url: {type:'string'}, key_token: {type:'string'} } +// +// 而合法形态必须是: +// +// parameters: { type:'object', properties:{ gateway_url:…, key_token:… } } +// +// 后果:11 个 mail-bridge 工具的 JSON Schema 全部非法。严格的上游直接 400 +// (实测 OpenCode Go:`Invalid schema for function 'connect_to_server': schema +// must be a JSON Schema of 'type: "object"', got 'type: null'`),而宽松的 +// 上游(Claude 系)不校验 —— 所以这个 bug 只在特定 AUTO 档位上暴露, +// 表现为“某个模型突然不能用了”。 +const _require = createRequire(import.meta.url); function defineTool(opts: any): any { if (!_defineTool) { try { - const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools'); - _defineTool = require(dshToolsPath).defineTool; - } catch { return opts; } + _defineTool = _require('@deepseek-ai/dsh-tools').defineTool; + } catch { + _defineTool = null; + } } - return _defineTool(opts); + if (_defineTool) return _defineTool(opts); + // 兜底也必须产出**合法** schema:宁可自己拼,也绝不能把裸映射发出去 + // (这正是上面那个 bug 的教训 —— 静默降级出一个非法产物,比直接报错更难查)。 + return { ...opts, parameters: parametersToJsonSchema(opts.parameters) }; +} + +/** + * 把 defineTool 的参数 spec 转成合法 JSON Schema。 + * + * 只作 dsh-tools 不可用时的兜底,形态与 `parameterSchemaSpecToJsonSchema` 对齐: + * 属性上的 `required: true` 被提升到根级 `required` 数组(JSON Schema 不允许 + * 属性自己带 `required`)。 + * + * 注意:仅处理顶层属性,不做嵌套属性映射的递归 —— 那是兜底的兜底,真走到 + * 这里说明依赖解析出了问题,应该先修依赖。 + */ +export function parametersToJsonSchema(spec: any): any { + const properties: Record = {}; + const required: string[] = []; + for (const [key, value] of Object.entries(spec ?? {})) { + const { required: isRequired, ...rest } = (value ?? {}) as any; + properties[key] = rest; + if (isRequired) required.push(key); + } + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + }; } // ─── Cordis 插件入口 ─── diff --git a/plugins/dsh-mail-bridge/test/tool-schema.test.mjs b/plugins/dsh-mail-bridge/test/tool-schema.test.mjs new file mode 100644 index 0000000..e09902d --- /dev/null +++ b/plugins/dsh-mail-bridge/test/tool-schema.test.mjs @@ -0,0 +1,69 @@ +// 回归:mail-bridge 的工具 schema 必须是合法 JSON Schema。 +// +// 曾经这里用裸 `require(...)` 载入 @deepseek-ai/dsh-tools。插件是 ESM, +// ESM 里没有 require → 抛 ReferenceError → 被 `catch { return opts; }` 静默吞掉, +// 于是 defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**注册: +// +// { gateway_url: {type:'string'}, key_token: {type:'string'} } +// +// 而不是: +// +// { type:'object', properties:{ gateway_url:…, key_token:… } } +// +// 严格的上游直接 400(实测 OpenCode Go): +// Invalid schema for function 'connect_to_server': +// schema must be a JSON Schema of 'type: "object"', got 'type: null'. +// 宽松的上游(Claude 系)不校验,所以只在特定 AUTO 档位暴露。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parametersToJsonSchema } from '../dist/index.js'; + +test('裸参数映射被编译成 type:object + properties', () => { + const got = parametersToJsonSchema({ + gateway_url: { type: 'string', description: 'Gateway 地址' }, + key_token: { type: 'string', description: '密钥' }, + }); + assert.equal(got.type, 'object', '缺 type:object 会被严格上游 400'); + assert.deepEqual(Object.keys(got.properties), ['gateway_url', 'key_token']); + // 属性必须原样保留 + assert.equal(got.properties.gateway_url.type, 'string'); + // 没有任何 required 时不应凭空产出空数组 + assert.equal('required' in got, false); +}); + +test('required:true 被提升到根级 required 数组,并从属性上移除', () => { + const got = parametersToJsonSchema({ + to: { type: 'string', required: true, description: '收件人' }, + cc: { type: 'string', description: '抄送' }, + }); + assert.deepEqual(got.required, ['to']); + // JSON Schema 的属性里不允许出现 required + assert.equal('required' in got.properties.to, false); + assert.equal(got.properties.to.type, 'string'); +}); + +test('数组属性与其 items 原样保留', () => { + const got = parametersToJsonSchema({ + attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件' }, + }); + assert.deepEqual(got.properties.attachment_ids.items, { type: 'string' }); +}); + +test('空 / 缺失 spec 产出合法的空对象 schema', () => { + for (const spec of [undefined, null, {}]) { + const got = parametersToJsonSchema(spec); + assert.equal(got.type, 'object'); + assert.deepEqual(got.properties, {}); + } +}); + +test('编译结果本身通过「必须有 type:object」的断言(上游的实际要求)', () => { + // 真机上被拒的那个工具 + const connect = parametersToJsonSchema({ + gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com;省略则用当前配置' }, + key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' }, + }); + assert.equal(connect.type, 'object'); + // 模拟上游校验:type 必须存在且为 object + assert.ok(connect.type === 'object', `got type: ${JSON.stringify(connect.type ?? null)}`); +});