From 61ecec70030d101e69d2943e3b06645d628260b5 Mon Sep 17 00:00:00 2001 From: EdgeMLHacker <126883848+Apageoflove@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:29:25 -0700 Subject: [PATCH] =?UTF-8?q?fix(minimax-bot):=20=E5=A3=B0=E6=98=8E=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E5=80=BC=E5=B0=B1=E7=85=A7=E5=A3=B0=E6=98=8E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=8C=E5=90=8D=E5=AD=97=E5=86=99=E5=9C=A8=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=A4=96=E7=9A=84=E5=A4=B4=E5=83=8F=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E8=AF=BB=E8=BF=9B=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 8 + .../minimax-bot/miniapp/node/mcp.mjs | 89 +++++- .../minimax-bot/miniapp/node/server.mjs | 24 +- .../minimax-bot/miniapp/node/strings.mjs | 30 +- .../minimax-bot/miniapp/node/team.mjs | 73 ++++- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../minimax-bot/tests/avatar-scope.test.mjs | 191 ++++++++++++ .../tests/enum-validation.test.mjs | 275 ++++++++++++++++++ .../tests/mcp-arg-surface.test.mjs | 165 +++++++++++ .../minimax-bot/tests/mcp-params.test.mjs | 154 ++++++++++ 11 files changed, 983 insertions(+), 30 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/tests/avatar-scope.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/enum-validation.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/mcp-arg-surface.test.mjs create mode 100644 plugins/minimax-ai/minimax-bot/tests/mcp-params.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 1549292..c1cd4a5 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.13", + "version": "0.7.15", "description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.", "author": "MiniMax Code", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index ca8e30a..c4b4864 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -30,6 +30,14 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes. - **Working messages of one reply**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text. +## 0.7.15 What the plugin refuses to guess + +A value the plugin declares is now checked at the door instead of being quietly turned into another legal value. A scheduled task whose `kind` is not `daily`, `interval` or `once` is refused with the list of the values it may take — leaving `kind` out is still the default daily task — and a group whose `reply_mode` is not `admin` or `all` is refused too, without touching the setting the group already had. The usage range has to be a whole number of days from 1 to 90; a value outside it gets an error instead of an empty chart that reads like the quota had been wiped. + +The MCP tools check the arguments they declare before they run. A value of the wrong JSON type is refused at the door, so a call that does not fit the schema never reaches the tool and never changes anything. For the enumerations a tool answers itself — the memory scope, the group reply mode, the schedule kind — the tool's own message is the one the model reads: it names the range and what the value would have changed, which a schema cannot. A failure that is not one of the plugin's own errors answers with a plain notice now, and the detail goes to the log instead of into the model's context. + +An avatar may come from the avatar library or from an image uploaded in the page only. A path on the computer is refused, the plugin reads no file outside its own `dataDir` for one, and the refused path is not echoed back — so the field cannot be used to ask what exists on the disk. + ## 0.7.10 Optional group notes - A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index b1382af..d2b2d34 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -3,6 +3,7 @@ import { EngineError } from './engine.mjs'; import { t } from './i18n.mjs'; import { ENTRY_MAX, MemoryError, TOPIC_KEY_RE } from './memory.mjs'; import { BOT_COLORS, BOT_SKINS, effortOptions, withEffort, newId } from './store.mjs'; +import { MEMORY_SCOPES } from './team.mjs'; /** @typedef {import('./team.mjs').Team} Team */ /** @typedef {import('./engine.mjs').Engine} Engine */ @@ -36,7 +37,7 @@ function toolDefs() { }; const memoryScope = { type: 'string', - enum: ['self', 'team', 'bot'], + enum: MEMORY_SCOPES, description: t('mcp.p.scope'), }; @@ -271,6 +272,67 @@ function toolDefs() { /** Tool names, for lookups that must not depend on the language. */ export const TOOL_NAMES = toolDefs().map((tool) => tool.name); + +/** Whether a value is of the JSON type a schema declares. */ +function isJsonType(type, value) { + if (type === 'string') return typeof value === 'string'; + if (type === 'boolean') return typeof value === 'boolean'; + if (type === 'integer') return Number.isInteger(value); + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'array') return Array.isArray(value); + if (type === 'object') return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + return true; +} + +/** + * The enums a tool answers itself. + * + * For these the tool's answer says more than the schema can: the range, and what the value + * would have changed had it been accepted. So the value reaches the tool and the model reads + * the tool's message, not a generic one. Every other declared enum is checked here, before the + * tool runs, so a value that would otherwise be read as a different legal one never changes + * anything. + */ +const TOOL_OWNED_ENUMS = new Set([ + 'remember.scope', 'forget.scope', 'recall.scope', + 'create_group.reply_mode', 'update_group.reply_mode', + 'create_schedule.kind', +]); + +/** + * What is wrong with the arguments of a tool call, judged by the inputSchema the tool itself + * declares: a value of the wrong JSON type, or a value outside a declared enum. The other rules + * of a tool (a topic a memory needs, a text that may not be longer than X) belong to the tool, + * which answers them with its own message; here only the shape of a value is checked, so a + * call that does not fit the schema never reaches the tool and never changes anything. + * @param {string} tool the tool being called, for the enums the tool answers itself + * @param {any} schema + * @param {any} args + * @returns {string[]} the problems, empty when the arguments fit the schema + */ +function schemaProblems(tool, schema, args) { + if (!schema || schema.type !== 'object') return []; + if (args === null || typeof args !== 'object' || Array.isArray(args)) { + return [t('mcp.err.argObject')]; + } + const problems = []; + for (const [name, value] of Object.entries(args)) { + // An argument the tool did not declare is left to the tool: dispatch_tasks, for one, has a + // message of its own for the options it does not support. + const property = schema.properties?.[name]; + if (!property || value === null || value === undefined) continue; + if (property.type && !isJsonType(property.type, value)) { + problems.push(t('mcp.err.argType', { name, type: property.type })); + continue; + } + if (Array.isArray(property.enum) && !property.enum.includes(value) + && !TOOL_OWNED_ENUMS.has(`${tool}.${name}`)) { + problems.push(t('mcp.err.argEnum', { name, values: property.enum.join(', '), value: String(value) })); + } + } + return problems; +} + /** * @param {{ team: Team, engine: Engine }} deps */ @@ -296,6 +358,20 @@ export function createMcpHandler({ team, engine }) { return bot; } + /** + * The text a failed tool call shows the model. A failure the plugin knows (EngineError, + * MemoryError) carries its own message, written for the model. Anything else is a fault of + * the plugin: the model gets a generic line it cannot act on anyway, and the detail goes + * to the log, where the operator reads it. + */ + function failureText(error, name) { + if (error instanceof EngineError || error instanceof MemoryError) return String(error.message); + engine.logger?.warn?.('minimax_bot.tool_failed', { + tool: name, code: error?.code ?? 'unknown', detail: String(error?.message ?? error), + }); + return t('mcp.err.internal'); + } + const LIVE = { // Sent as keys so the page renders them in its own language. message_bot: (a) => ({ key: 'live.askBot', params: { name: store.getBot(a.to)?.name ?? a.to ?? t('mcp.anotherBot') } }), @@ -586,7 +662,7 @@ export function createMcpHandler({ team, engine }) { result = { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'minimax-bot', version: '0.7.13' }, + serverInfo: { name: 'minimax-bot', version: '0.7.15' }, instructions: t('mcp.instructions'), }; break; @@ -606,6 +682,12 @@ export function createMcpHandler({ team, engine }) { if (!TOOL_NAMES.includes(name)) { return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } }; } + const tool = toolDefs().find((entry) => entry.name === name); + const problems = schemaProblems(name, tool?.inputSchema, args); + if (problems.length) { + result = { content: [{ type: 'text', text: t('mcp.err.badArgs', { tool: name, problems }) }], isError: true }; + break; + } const callKey = session ? `${session}:${JSON.stringify(id)}` : newId('rpc'); if (calls.has(callKey)) return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Request already running' } }; const controller = new AbortController(); @@ -614,8 +696,7 @@ export function createMcpHandler({ team, engine }) { const value = await callTool(name, args, controller.signal); result = { content: [{ type: 'text', text: JSON.stringify(value, null, name === 'recall' ? 0 : 2) }] }; } catch (error) { - const text = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error); - result = { content: [{ type: 'text', text }], isError: true }; + result = { content: [{ type: 'text', text: failureText(error, name) }], isError: true }; } finally { calls.delete(callKey); } break; } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs index ce8f55a..ccfa513 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs @@ -22,6 +22,11 @@ import { markdownDocument, HTML_PREVIEW_CSP, MARKDOWN_PREVIEW_CSP } from './docu export const USER_NAME_MAX = 24; +/** The usage panel's range: a whole number of days, from one day to three months. */ +const USAGE_DAYS_DEFAULT = 7; +const USAGE_DAYS_MIN = 1; +const USAGE_DAYS_MAX = 90; + /** * The account's display name: the macOS full name (`id -F`), otherwise the login name. The Host * exposes no user identity to Mini Apps, so this is the best default; the user can override it. @@ -178,9 +183,20 @@ export async function start(context) { return { scope, entries, chars: memoryBudget(entries).used, budget: memoryBudget(entries), contextBudget: { limit: MEMORY_CONTEXT_MAX, unit: 'characters' } }; }; - const usage = async (days) => { - const hourly = days <= 1; - const span = hourly ? 24 : Math.max(1, Math.min(90, days)); + /** The usage panel asks for a number of days; anything but a whole 1..90 is refused. */ + const usageDays = (raw) => { + if (raw === undefined || raw === null) return USAGE_DAYS_DEFAULT; + const days = Number(raw); + if (!Number.isInteger(days) || days < USAGE_DAYS_MIN || days > USAGE_DAYS_MAX) { + throw new EngineError('invalid', t('err.usageDays', { min: USAGE_DAYS_MIN, max: USAGE_DAYS_MAX })); + } + return days; + }; + + const usage = async (raw) => { + const days = usageDays(raw); + const hourly = days === 1; + const span = hourly ? 24 : days; const pad = (n) => String(n).padStart(2, '0'); const start = new Date(); if (hourly) { @@ -658,7 +674,7 @@ export async function start(context) { } if (method === 'GET' && path === '/api/usage') { - return json(response, 200, await usage(Number(url.searchParams.get('days') ?? 7))); + return json(response, 200, await usage(url.searchParams.get('days'))); } json(response, 404, { error: 'not_found', message: t('err.routeNotFound') }); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs index 4a36252..1eb7637 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs @@ -91,6 +91,8 @@ export default { 'memory.defaultSource': '对话', 'memory.manualSource': '用户手动添加', 'memory.err.scope': '无效的记忆范围', + 'memory.err.badScope': '无效的记忆范围「{scope}」,只能是 {list} 之一。', + 'memory.err.noCaller': '缺少调用方(caller_bot_id):这次操作需要一个 Bot 身份。', 'memory.err.pinned': '优先带入上下文须为布尔值。', 'memory.err.pending': '记忆变更尚待恢复,请重试;系统会自动完成同一操作,不会重复记录。', 'memory.err.full': "长期记忆未保存:存储上限 {max} 字符,剩余 {remaining},本次增加 {needed}。这不是模型上下文余额;旧记忆完整保留,请勿删除无关主题腾空间。", @@ -146,10 +148,13 @@ export default { 'err.otherMemoryForbidden': '只有主 Bot 能读写其他 Bot 的记忆', 'err.schedulePrompt': '请填写要让 Bot 做的事', 'err.scheduleTime': '时间格式应为 HH:MM', + 'err.scheduleKind': 'kind 只能是 {allowed} 之一', 'err.scheduleInMinutes': '一次性提醒要给出 in_minutes:1 到 10080 之间的分钟数', 'err.scheduleTargetRequired': '请用 target_bot 指定由哪个 Bot 执行', 'err.scheduleOthersForbidden': '只有主 Bot 能给其他 Bot 安排定时任务', 'err.scheduleNotFound': '找不到定时任务 {id}', + 'err.replyMode': 'reply_mode 只能是 {allowed} 之一', + 'err.usageDays': 'days 必须是 {min} 到 {max} 之间的整数', // engine 'err.noAgentSupport': '当前 MiniMax Code 不支持 Mini App 运行 Agent', 'err.emptyMessage': '消息不能为空', @@ -210,9 +215,8 @@ export default { 'err.helperLimit': '这个 Bot 已经有 {n} 个后台任务在排队或运行,等它们完成,或先停掉一个', 'err.helperMissing': '找不到 helper {id}', 'err.helperNotYours': '只能停止自己派出的 helper', - 'err.badAvatar': '头像必须是 PNG、JPG、WebP 或 GIF 图片(本机绝对路径)', + 'err.badAvatar': '头像必须是 PNG、JPG、WebP 或 GIF 图片(取自头像图库或页面上传),本机文件路径不是头像来源', 'err.avatarTooLarge': '头像图片太大(最多 {max}),先缩小到 512px 以内再设置', - 'err.avatarNotFound': '找不到头像图片:{path}', 'err.modelsUnsupported': '当前版本的 MiniMax Code 还不支持给 Bot 单独设置模型,请升级后再试', 'err.modelNotFound': '找不到模型「{model}」。可用的模型:{list}。传 default 表示跟随默认模型', // prompts sent to Bots (model-facing) @@ -274,6 +278,11 @@ export default { // MCP (model-facing) 'mcp.instructions': 'MiniMax Bot 团队工具。Bot 调用时请传 caller_bot_id。', 'mcp.err.badCaller': 'caller_bot_id「{id}」不是团队里的 Bot', + 'mcp.err.badArgs': (p) => `参数不合法:${(p.problems ?? []).join(';')}。请按 ${p.tool} 声明的格式修正后重试。`, + 'mcp.err.argType': '{name} 必须是 {type} 类型', + 'mcp.err.argEnum': '{name} 只能是 {values} 之一(收到 {value})', + 'mcp.err.argObject': '参数必须是一个 JSON 对象', + 'mcp.err.internal': '工具执行失败,详细原因已写入日志。请改用其他方式或稍后重试。', 'mcp.err.relayFailed': '{name} 没有完成这次回复(状态:{status})', 'mcp.err.unknownTool': '未知工具 {name}', 'mcp.anotherBot': '另一个 Bot', @@ -322,7 +331,7 @@ export default { 'mcp.p.effort': '推理强度:low / medium / high / xhigh / max 等(以模型支持为准),off 表示关闭思考,default 表示用模型默认。作用在这个 Bot 用的模型上(没指定模型就是默认模型)。不传就不改;用户没提就不要传', 'mcp.p.skin': '形象:fuzzy 蓝色毛绒、bubble 泡泡、glass 粉色玻璃、gold 金色、jade 翡翠、plush 毛绒、waffle 华夫饼、pixel 像素、smiley 笑脸球、toys 玩具、moon 月岩。创建时给每个 Bot 选不同的形象;不传就自动挑一个用得最少的', 'mcp.p.autoHelper': '是否允许自动使用后台助手(默认 true)。传 false 则只有用户明确要求时才使用', - 'mcp.p.avatar': '自定义头像:本机图片的绝对路径(PNG/JPG/WebP/GIF,最多 2MB,最好是正方形);传 default 换回章鱼形象。不传就不改', + 'mcp.p.avatar': '自定义头像:头像图库里的文件名(PNG/JPG/WebP/GIF);传 default 换回章鱼形象。不传就不改', 'mcp.p.model': '这个 Bot 用的模型:模型 ID(如 MiniMax-M3.1)、「provider/模型」或显示名;传 default 跟随默认模型。不传就不改。用户没指定模型时不要传', 'mcp.t.delete_bot': '(仅主 Bot,且只在用户明确要求时)删除一个 Bot,它的私聊会被清空,记忆会归档。', 'mcp.t.create_group': '(仅主 Bot)创建群聊。管理员默认接用户的话,并可以用 @名字 把任务交给其他成员。', @@ -406,6 +415,8 @@ export default { 'memory.defaultSource': 'conversation', 'memory.manualSource': 'added by the user', 'memory.err.scope': 'Invalid memory scope', + 'memory.err.badScope': 'Invalid memory scope "{scope}"; it must be one of {list}.', + 'memory.err.noCaller': 'No caller (caller_bot_id): this operation needs a Bot identity.', 'memory.err.pinned': 'Memory priority must be a boolean.', 'memory.err.pending': 'Memory change is pending recovery. Retry to finish the same operation without duplicating its journal entry.', 'memory.err.full': "Memory was not saved: storage limit {max} characters, remaining {remaining}, added cost {needed}. This is not model context capacity. Existing memories are unchanged; do not delete unrelated topics.", @@ -458,10 +469,13 @@ export default { 'err.otherMemoryForbidden': "Only a main Bot can read or write another Bot's memory", 'err.schedulePrompt': 'Describe what the Bot should do', 'err.scheduleTime': 'Use the HH:MM time format', + 'err.scheduleKind': 'kind must be one of: {allowed}', 'err.scheduleInMinutes': 'A one-time reminder needs in_minutes: a number of minutes from 1 to 10080', 'err.scheduleTargetRequired': 'Use target_bot to say which Bot runs it', 'err.scheduleOthersForbidden': 'Only a main Bot can schedule tasks for other Bots', 'err.scheduleNotFound': "Couldn't find scheduled task {id}", + 'err.replyMode': 'reply_mode must be one of: {allowed}', + 'err.usageDays': 'days must be a whole number from {min} to {max}', 'err.noAgentSupport': "This version of MiniMax Code can't run Agents for Mini Apps", 'err.emptyMessage': "The message can't be empty", 'err.messageIdInvalid': 'Invalid message submission ID. Send the message again.', @@ -521,9 +535,8 @@ export default { 'err.helperLimit': 'This Bot already has {n} helpers queued or running. Wait for one to finish, or stop one with stop_helper', 'err.helperMissing': 'No helper {id}', 'err.helperNotYours': 'A Bot can only stop helpers it started', - 'err.badAvatar': 'An avatar must be a PNG, JPG, WebP or GIF image (an absolute local path)', + 'err.badAvatar': 'An avatar must be a PNG, JPG, WebP or GIF image (from the avatar library or a page upload); a local file path is not an avatar source', 'err.avatarTooLarge': 'The avatar image is too large (at most {max}). Shrink it to 512px or less first', - 'err.avatarNotFound': 'Avatar image not found: {path}', 'err.modelsUnsupported': "This version of MiniMax Code can't set a model per Bot yet. Update it and try again", 'err.modelNotFound': 'No model named "{model}". Available models: {list}. Pass default to follow the default model', 'group.newMessages': 'New messages in the group "{name}":', @@ -583,6 +596,11 @@ export default { 'brief.reply': "Reply in the user's language and lead with the answer. Use ask_user when the user needs to make a choice. Tool calls and error details are available in execution records; do not narrate each tool error. Continue normally when a recovered failure does not affect delivery. If delivery is incomplete or uncertain, briefly state what is done, what remains incomplete or unconfirmed, and any action the user needs to take. Acceptance, another successful call with the same tool name, or missing error details do not prove delivery. Do not repeat side-effecting operations with unknown outcomes just to clear errors.", 'mcp.instructions': 'MiniMax Bot team tools. Bots must pass caller_bot_id.', 'mcp.err.badCaller': 'caller_bot_id "{id}" is not a Bot on the team', + 'mcp.err.badArgs': (p) => `Invalid arguments: ${(p.problems ?? []).join('; ')}. Fix them to match the schema ${p.tool} declares and try again.`, + 'mcp.err.argType': '{name} must be of type {type}', + 'mcp.err.argEnum': '{name} must be one of {values} (got {value})', + 'mcp.err.argObject': 'The arguments must be a JSON object', + 'mcp.err.internal': 'The tool failed; the details are in the log. Use another way or try again later.', 'mcp.err.relayFailed': "{name} didn't finish this reply (status: {status})", 'mcp.err.unknownTool': 'Unknown tool {name}', 'mcp.anotherBot': 'another Bot', @@ -631,7 +649,7 @@ export default { 'mcp.p.effort': "Reasoning effort: low / medium / high / xhigh / max and so on (whatever the model offers), off to turn thinking off, default for the model's own default. Applies to the model this Bot runs on (the default model if it has none). Omit to leave it unchanged; do not pass it unless the user asked", 'mcp.p.skin': 'Look: fuzzy (blue plush), bubble, glass (pink glass), gold, jade, plush, waffle, pixel, smiley (a bag of smileys), toys, moon (moon rock). Give each new Bot a different look; omit it and the least-used one is picked', 'mcp.p.autoHelper': 'Whether background helpers may be used automatically (default true). Pass false to use them only when the user explicitly asks', - 'mcp.p.avatar': 'Custom avatar: absolute path to a local image (PNG/JPG/WebP/GIF, at most 2MB, ideally square); pass default to go back to the octopus. Omit to leave it unchanged', + 'mcp.p.avatar': 'Custom avatar: a file name from the avatar library (PNG/JPG/WebP/GIF); pass default to go back to the octopus. Omit to leave it unchanged', 'mcp.p.model': "The model this Bot runs on: a model ID (e.g. MiniMax-M3.1), \"provider/model\", or a display name; pass default to follow the default model. Omit to leave it unchanged. Do not pass it unless the user named a model", 'mcp.t.delete_bot': '(Main Bots only, and only when the user explicitly asks) Delete a Bot. Its chat is cleared and its memory archived.', 'mcp.t.create_group': '(Main Bots only) Create a group. The admin replies to the user by default and can hand tasks to other members with @name.', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs index 3be81ea..cbb6682 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs @@ -1,7 +1,7 @@ // @ts-check import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { isAbsolute, join } from 'node:path'; +import { join } from 'node:path'; import { BOT_SKINS, SKIN_INK, effortOf, newId, normalizeEffort, normalizeFace, normalizeModel, normalizeSkin, skinForColor } from './store.mjs'; import { EngineError, nextRun } from './engine.mjs'; import { hopLimit } from './relay-guard.mjs'; @@ -13,12 +13,48 @@ import { isDefaultText, resolveLocale, setLocale, t } from './i18n.mjs'; /** @typedef {import('./memory.mjs').MemoryStore} MemoryStore */ /** @typedef {import('./engine.mjs').Engine} Engine */ +/** The memory scopes a request may name. The MCP catalogue declares the same list. */ +export const MEMORY_SCOPES = /** @type {const} */ (['self', 'team', 'bot']); + /** An uploaded avatar the page already scaled down; data URLs above this are refused. */ const MAX_AVATAR_BYTES = 512 * 1024; /** An image file a Bot names by path; it is stored as-is, so it must already be small. */ const MAX_AVATAR_FILE_BYTES = 2 * 1024 * 1024; const AVATAR_NAME_RE = /^[a-zA-Z0-9_-]+\.(png|jpe?g|webp|gif)$/; +/** The kinds a scheduled task can have — the ones the catalogue offers. */ +const SCHEDULE_KINDS = /** @type {readonly string[]} */ (['daily', 'interval', 'once']); +/** Who answers in a group: the admin alone, or every member. */ +const REPLY_MODES = /** @type {readonly string[]} */ (['admin', 'all']); + +/** + * The kind of a scheduled task. A kind the catalogue does not offer is refused: any other kind + * would quietly run at a time the user never asked for. + * @param {unknown} value + * @returns {'daily' | 'interval' | 'once'} + */ +function scheduleKindOf(value) { + const kind = String(value); + if (!SCHEDULE_KINDS.includes(kind)) { + throw new EngineError('invalid', t('err.scheduleKind', { allowed: SCHEDULE_KINDS.join(', ') })); + } + return /** @type {'daily' | 'interval' | 'once'} */ (kind); +} + +/** + * Who answers in a group. A mode the catalogue does not offer is refused: the other mode would + * quietly change who hears the Bots. + * @param {unknown} value + * @returns {'admin' | 'all'} + */ +function replyModeOf(value) { + const mode = String(value); + if (!REPLY_MODES.includes(mode)) { + throw new EngineError('invalid', t('err.replyMode', { allowed: REPLY_MODES.join(', ') })); + } + return /** @type {'admin' | 'all'} */ (mode); +} + /** The image type from its first bytes, so a renamed file of another kind is refused. */ function imageExt(buffer) { if (buffer.length >= 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png'; @@ -187,8 +223,9 @@ export class Team { /** * The image an `avatar` value names: a `data:image/...;base64,` upload from the page, or an - * existing gallery file name, or absolute path to a local image (how a Bot sets one). `null` / `''` / `default` mean "back to - * the octopus" and return `undefined`. Anything unreadable or not an image is refused. + * existing gallery file name. `null` / `''` / `default` mean "back to + * the octopus" and return `undefined`. Anything else is refused: a path on this computer is + * not an avatar source, so no file outside this Mini App's data directory is ever read. * @returns {Promise<{ buffer: Buffer, ext: string, name?: string } | undefined>} */ async avatarImage(value) { @@ -210,12 +247,9 @@ export class Team { if (!info?.isFile() || info.size > MAX_AVATAR_FILE_BYTES) throw new EngineError('invalid', t('err.badAvatar')); buffer = await readFile(path); } else { - const path = text.startsWith('~/') ? join(process.env.HOME ?? '', text.slice(2)) : text; - if (!isAbsolute(path)) throw new EngineError('invalid', t('err.badAvatar')); - const info = await stat(path).catch(() => undefined); - if (!info?.isFile()) throw new EngineError('invalid', t('err.avatarNotFound', { path: text })); - if (info.size > MAX_AVATAR_FILE_BYTES) throw new EngineError('invalid', t('err.avatarTooLarge', { max: '2MB' })); - buffer = await readFile(path); + // A file path is not an avatar source: the plugin only ever reads its own gallery, so a + // path can neither pull a file in from elsewhere on this computer nor name one out loud. + throw new EngineError('invalid', t('err.badAvatar')); } const ext = imageExt(buffer); if (!ext) throw new EngineError('invalid', t('err.badAvatar')); @@ -328,7 +362,7 @@ export class Team { notice: String(input.notice ?? '').trim().slice(0, 500), adminId, memberIds, - replyMode: input.replyMode === 'all' ? 'all' : 'admin', + replyMode: input.replyMode === undefined ? 'admin' : replyModeOf(input.replyMode), pinned: false, createdAt: Date.now(), // F7 · the relay guardrails. The hop limit counts how many Bots may speak in one relay @@ -368,7 +402,7 @@ export class Team { const draft = { ...group, memberIds: [...group.memberIds] }; if (patch.name !== undefined) draft.name = this.validateName(patch.name, 'group', group.id); if (patch.notice !== undefined) draft.notice = String(patch.notice).trim().slice(0, 500); - if (patch.replyMode !== undefined) draft.replyMode = patch.replyMode === 'all' ? 'all' : 'admin'; + if (patch.replyMode !== undefined) draft.replyMode = replyModeOf(patch.replyMode); if (patch.pinned !== undefined) draft.pinned = Boolean(patch.pinned); // The relay guardrails: absent leaves the group's own setting alone. 0 = no limit. if (patch.maxHops !== undefined) draft.maxHops = hopLimit({ maxHops: patch.maxHops }); @@ -416,13 +450,17 @@ export class Team { * @param {boolean} write */ memoryScope(caller, scope, targetBot, write) { - if (scope === 'team') { + const requested = scope ?? 'self'; + if (!MEMORY_SCOPES.includes(requested)) { + throw new EngineError('invalid', t('memory.err.badScope', { scope: requested, list: MEMORY_SCOPES.join(', ') })); + } + if (requested === 'team') { if (write && caller && !caller.isMain && !this.store.state.groups.some((g) => g.adminId === caller.id)) { throw new EngineError('forbidden', t('err.teamMemoryForbidden')); } return 'team'; } - if (scope === 'bot' || (!caller && scope === 'self')) { + if (requested === 'bot' || (!caller && requested === 'self')) { if (!targetBot) throw new EngineError('invalid', t('err.targetBotRequired')); const target = this.resolveBot(targetBot); if (caller && caller.id !== target.id && !caller.isMain) { @@ -430,6 +468,8 @@ export class Team { } return target.id; } + // 'self' for a Bot: its own memory. A request without a caller has no own memory to name. + if (!caller) throw new EngineError('invalid', t('memory.err.noCaller')); return caller.id; } @@ -465,6 +505,9 @@ export class Team { } async recall(caller, input) { + if (input.scope != null && !MEMORY_SCOPES.includes(input.scope)) { + throw new EngineError('invalid', t('memory.err.badScope', { scope: input.scope, list: MEMORY_SCOPES.join(', ') })); + } let scopes; if (input.scope === 'team') scopes = ['team']; else if (input.scope === 'bot' || (!caller && input.targetBot)) { @@ -528,7 +571,9 @@ export class Team { if (!prompt) throw new EngineError('invalid', t('err.schedulePrompt')); const list = this.store.state.schedules; const existing = input.id ? list.find((s) => s.id === input.id) : undefined; - const kind = ['interval', 'once'].includes(String(input.kind)) ? String(input.kind) : 'daily'; + // No kind at all is the daily default. A kind that is not one of the three is refused: + // another kind would run at a time the user never asked for. + const kind = input.kind === undefined ? 'daily' : scheduleKindOf(input.kind); const time = String(input.time ?? '09:00'); if (kind === 'daily' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) { throw new EngineError('invalid', t('err.scheduleTime')); diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index 17be032..8715693 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.13", + "version": "0.7.15", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/avatar-scope.test.mjs b/plugins/minimax-ai/minimax-bot/tests/avatar-scope.test.mjs new file mode 100644 index 0000000..417eb51 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/avatar-scope.test.mjs @@ -0,0 +1,191 @@ +// @ts-check +// The avatar field must not become a way to read files off this computer. +// +// The point of this file: the claims the fix makes are not left to a code review. +// +// 1. an absolute path outside the Mini App's data directory is refused, the file is never +// read, and nothing lands in the avatar library; +// 2. the refusal says nothing about the file: the path is not echoed, and an existing +// image, an existing non-image and a missing path all get the same answer, so the field +// is not an existence-and-type probe of the file system; +// 3. a file this process may not even open is refused the same clean way, which is only +// possible if nobody tried to open it; +// 4. the model-facing tools (create_bot / update_bot) refuse the same path; +// 5. the library file name branch is untouched: a Bot still gets an avatar from the +// library, and the page's own upload still lands in the library. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const pluginRoot = join(import.meta.dirname, '..'); +const { start } = await import(pathToFileURL(join(pluginRoot, 'miniapp/node/server.mjs')).href); + +/** A real 1x1 PNG, so the plugin's own image sniffing accepts it. */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +async function fixture(t) { + const dataDir = await mkdtemp(join(tmpdir(), 'bot-avatar-')); + const outside = await mkdtemp(join(tmpdir(), 'bot-outside-')); + const probe = createServer(); + await new Promise((resolve, reject) => { probe.once('error', reject); probe.listen(0, '127.0.0.1', resolve); }); + const port = probe.address().port; + await new Promise((resolve, reject) => probe.close((error) => (error ? reject(error) : resolve()))); + const app = await start({ + pluginRoot, + dataDir, + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {} }, + }); + const base = `http://127.0.0.1:${port}`; + t.after(async () => { + await app.dispose(); + await rm(dataDir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + return { + dataDir, + outside, + base, + /** The library on disk: what the plugin copied in, if anything. */ + library: () => readdir(join(dataDir, 'avatars')).catch(() => []), + }; +} + +/** `POST /api/bots`, the page's own route. */ +async function postBot(base, name, avatar) { + const response = await fetch(`${base}/api/bots`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, avatar }), + }); + return { status: response.status, body: await response.json() }; +} + +/** One MCP tool call, the way a Bot reaches the plugin. */ +async function callTool(base, name, args) { + const response = await fetch(`${base}/mcp/minimax-bot`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), + }); + return (await response.json()).result; +} + +const messageOf = (result) => String(result?.content?.[0]?.text ?? ''); + +// ---------------------------------------------------------------- 1. the file is not read + +test('an absolute path outside the data directory is refused, and the file is never read', async (t) => { + const { outside, base, library } = await fixture(t); + const secret = join(outside, 'outside-secret.png'); + await writeFile(secret, PNG); + + const result = await postBot(base, 'Escaped', secret); + assert.equal(result.status, 400, 'a path outside the data directory is not an avatar source'); + assert.equal(result.body.error, 'invalid'); + assert.deepEqual(await library(), [], 'the file did not land in the avatar library'); + + const state = await (await fetch(`${base}/api/state`)).json(); + assert.ok(!state.bots.some((bot) => bot.name === 'Escaped'), 'the Bot was not created'); + + const gallery = await (await fetch(`${base}/api/avatars`)).json(); + assert.deepEqual(gallery.avatars, [], 'the library lists nothing'); +}); + +// ---------------------------------------------------------------- 2. the answer says nothing + +test('every outside path gets the same answer, so the field cannot probe the file system', async (t) => { + const { outside, base } = await fixture(t); + const image = join(outside, 'there.png'); + const text = join(outside, 'there.txt'); + await writeFile(image, PNG); + await writeFile(text, 'not an image at all'); + + const answers = []; + for (const value of [image, text, join(outside, 'missing.png')]) { + answers.push(await postBot(base, 'Probe', value)); + } + for (const answer of answers) { + assert.equal(answer.status, 400); + assert.ok(!answer.body.message.includes(outside), 'no part of the path comes back'); + } + assert.equal(answers[0].body.message, answers[1].body.message, 'an existing image and an existing non-image answer alike'); + assert.equal(answers[0].body.message, answers[2].body.message, 'a missing path answers alike'); +}); + +// ---------------------------------------------------------------- 3. nobody opened the file + +test('a file this process may not open is refused the same clean way, so nobody tried to open it', async (t) => { + const { outside, base, library } = await fixture(t); + const locked = join(outside, 'locked.png'); + await writeFile(locked, PNG); + await chmod(locked, 0o000); + + const result = await postBot(base, 'Locked', locked); + assert.equal(result.status, 400, 'the plugin answers about the avatar, not about a permission error'); + assert.equal(result.body.error, 'invalid'); + assert.ok(!result.body.message.includes(outside), 'no part of the path comes back'); + assert.deepEqual(await library(), [], 'nothing landed in the avatar library'); +}); + +// ---------------------------------------------------------------- 4. the model-facing tools + +test('the model-facing tools refuse the same path', async (t) => { + const { outside, base, library } = await fixture(t); + const secret = join(outside, 'outside-secret.png'); + await writeFile(secret, PNG); + + const created = await callTool(base, 'create_bot', { name: 'Mcp Escaped', avatar: secret }); + assert.equal(created.isError, true, 'create_bot refuses the path'); + assert.ok(!messageOf(created).includes(outside), 'the path is not echoed'); + assert.deepEqual(await library(), [], 'nothing landed in the avatar library'); + + const plain = await callTool(base, 'create_bot', { name: 'Mcp Plain' }); + assert.notEqual(plain.isError, true, 'a Bot without an avatar is still created'); + const botId = JSON.parse(messageOf(plain)).bot_id; + const updated = await callTool(base, 'update_bot', { bot: botId, avatar: secret }); + assert.equal(updated.isError, true, 'update_bot refuses the path'); + assert.ok(!messageOf(updated).includes(outside), 'the path is not echoed'); + assert.deepEqual(await library(), [], 'nothing landed in the avatar library'); +}); + +// ---------------------------------------------------------------- 5. the library branch is untouched + +test('a library file name still sets an avatar, and the library file is not copied again', async (t) => { + const { dataDir, base, library } = await fixture(t); + await mkdir(join(dataDir, 'avatars'), { recursive: true }); + await writeFile(join(dataDir, 'avatars', 'library.png'), PNG); + + const result = await postBot(base, 'Library', 'library.png'); + assert.equal(result.status, 200); + assert.equal(result.body.bot.avatar, 'library.png', 'the library file name is kept as it is'); + assert.deepEqual(await library(), ['library.png'], 'the library file is not copied again'); + + const served = await fetch(`${base}/api/avatars/library.png`); + assert.equal(served.status, 200); + assert.equal(served.headers.get('content-type'), 'image/png'); + assert.deepEqual(Buffer.from(await served.arrayBuffer()), PNG, 'the library file is served as it is'); +}); + +test('the page upload still lands in the library', async (t) => { + const { dataDir, base, library } = await fixture(t); + const response = await fetch(`${base}/api/avatars`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ avatar: `data:image/png;base64,${PNG.toString('base64')}` }), + }); + assert.equal(response.status, 200); + const { avatar } = await response.json(); + assert.match(avatar, /^avatar-[a-f0-9]{64}\.png$/); + assert.deepEqual(await library(), [avatar]); + assert.deepEqual(await readFile(join(dataDir, 'avatars', avatar)), PNG, 'the uploaded image is stored as it is'); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/enum-validation.test.mjs b/plugins/minimax-ai/minimax-bot/tests/enum-validation.test.mjs new file mode 100644 index 0000000..1825a5f --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/enum-validation.test.mjs @@ -0,0 +1,275 @@ +// @ts-check +// The enums this plugin declares are checked at the door, not merely written down. +// +// The point of this file: a value the catalogue does not offer is refused, at every entrance, +// instead of being quietly turned into a different — legal — value. A schedule that becomes +// "daily" is a daily e-mail the user did not ask for; a group whose reply mode flips from +// "all" to "admin" is a group that went quiet; a usage range that becomes NaN is an empty +// chart that reads like a wiped quota. +// +// 1. an unknown schedule kind is refused, and nothing is scheduled in its place; +// 2. a schedule with no kind at all is a daily task — the default, not a fallback; +// 3. an unknown reply mode is refused, and the group keeps the setting it had; +// 4. the usage range is a whole number of days, or the request is refused; +// 5. the MCP entrance and the HTTP entrance agree on all of it. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// The marketplace keeps the regression tests outside the distributed plugin directory. +const pluginRoot = resolve(process.env.BOT_PLUGIN_ROOT ?? resolve(import.meta.dirname, '..')); +const load = async (relative) => import(pathToFileURL(join(pluginRoot, relative)).href); + +const { Store } = await load('miniapp/node/store.mjs'); +const { MemoryStore } = await load('miniapp/node/memory.mjs'); +const { Engine } = await load('miniapp/node/engine.mjs'); +const { Team } = await load('miniapp/node/team.mjs'); +const { createMcpHandler } = await load('miniapp/node/mcp.mjs'); +const { setLocale } = await load('miniapp/node/i18n.mjs'); +const { start } = await load('miniapp/node/server.mjs'); + +async function teamFixture(t, locale = 'en') { + const dir = await mkdtemp(join(tmpdir(), 'bot-enum-validation-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale(locale); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: () => {}, retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast: () => {} }); + const handle = createMcpHandler({ team, engine }); + const rpc = async (name, args) => { + let response; + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { response = JSON.parse(body); } }, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + return response.result; + }; + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + return { dir, store, memory, engine, team, rpc }; +} + +async function httpFixture(t) { + const dataDir = await mkdtemp(join(tmpdir(), 'bot-enum-http-')); + const probe = createServer(); + await new Promise((res, rej) => { probe.once('error', rej); probe.listen(0, '127.0.0.1', res); }); + const port = probe.address().port; + await new Promise((res, rej) => probe.close((error) => (error ? rej(error) : res()))); + const app = await start({ + pluginRoot, dataDir, listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {} }, + }); + const base = `http://127.0.0.1:${port}`; + const post = async (path, body) => { + const response = await fetch(`${base}${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + return { status: response.status, body: await response.json() }; + }; + const patch = async (path, body) => { + const response = await fetch(`${base}${path}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + return { status: response.status, body: await response.json() }; + }; + const get = async (path) => { + const response = await fetch(`${base}${path}`); + return { status: response.status, body: await response.json() }; + }; + t.after(async () => { + try { await app.dispose(); } + finally { await rm(dataDir, { recursive: true, force: true }); } + }); + return { base, post, patch, get }; +} + +const writer = (team) => team.createBot(undefined, { name: 'Writer', role: 'writer', isMain: true }); + +// ---------------------------------------------------------------- 1. the schedule kind + +test('an unknown schedule kind is refused instead of becoming another kind', async (t) => { + const { team, store } = await teamFixture(t); + const bot = await writer(team); + await assert.rejects( + () => team.saveSchedule({ botId: bot.id, prompt: 'Send the weekly report', kind: 'weekly' }), + (error) => { + assert.equal(error.code, 'invalid'); + // The message names the values the catalogue offers, so the model can correct itself. + assert.match(error.message, /daily/); + assert.match(error.message, /interval/); + assert.match(error.message, /once/); + return true; + }, + ); + assert.deepEqual(store.state.schedules, [], 'no task was scheduled in its place'); +}); + +test('every other unknown kind is refused the same way', async (t) => { + const { team, store } = await teamFixture(t); + const bot = await writer(team); + for (const kind of ['weekly', 'Daily', 'daily ', 'every', '', 'null']) { + await assert.rejects( + () => team.saveSchedule({ botId: bot.id, prompt: 'Report', kind }), + (error) => { assert.equal(error.code, 'invalid', `kind=${JSON.stringify(kind)}`); return true; }, + ); + } + assert.deepEqual(store.state.schedules, []); +}); + +test('the MCP schedule tool refuses an unknown kind and leaves no task behind', async (t) => { + const { team, store, rpc } = await teamFixture(t); + const bot = await writer(team); + const result = await rpc('create_schedule', { prompt: 'Send the weekly report', kind: 'weekly', target_bot: bot.id }); + assert.equal(result.isError, true, 'the tool call failed'); + assert.match(result.content[0].text, /daily/); + assert.deepEqual(store.state.schedules, []); +}); + +test('the HTTP schedule entry refuses an unknown kind too', async (t) => { + const { post } = await httpFixture(t); + const bot = (await post('/api/bots', { name: 'Writer', role: 'writer', isMain: true })).body.bot; + const schedule = await post('/api/schedules', { botId: bot.id, prompt: 'Send the weekly report', kind: 'weekly' }); + assert.equal(schedule.status, 400); + assert.equal(schedule.body.error, 'invalid'); + assert.ok(schedule.body.message, 'the refusal says why'); +}); + +// ---------------------------------------------------------------- 2. the default kind + +test('a schedule without a kind is a daily task — the default, not a fallback', async (t) => { + const { team, store } = await teamFixture(t); + const bot = await writer(team); + const schedule = await team.saveSchedule({ botId: bot.id, prompt: 'Daily digest' }); + assert.equal(schedule.kind, 'daily'); + assert.equal(schedule.time, '09:00'); + assert.equal(store.state.schedules.length, 1); + assert.equal(store.state.schedules[0].kind, 'daily'); +}); + +test('the declared kinds are still accepted', async (t) => { + const { team } = await teamFixture(t); + const bot = await writer(team); + assert.equal((await team.saveSchedule({ botId: bot.id, prompt: 'Daily', kind: 'daily' })).kind, 'daily'); + assert.equal((await team.saveSchedule({ botId: bot.id, prompt: 'Hourly', kind: 'interval', everyMinutes: 30 })).kind, 'interval'); + assert.equal((await team.saveSchedule({ botId: bot.id, prompt: 'Once', kind: 'once', inMinutes: 10 })).kind, 'once'); +}); + +// ---------------------------------------------------------------- 3. the reply mode + +test('an unknown reply mode is refused when a group is created', async (t) => { + const { team, store } = await teamFixture(t); + const bot = await writer(team); + await assert.rejects( + () => team.createGroup(undefined, { name: 'Crew', members: [bot.id], replyMode: 'ALL' }), + (error) => { + assert.equal(error.code, 'invalid'); + assert.match(error.message, /admin/); + assert.match(error.message, /all/); + return true; + }, + ); + assert.deepEqual(store.state.groups, [], 'no group was created'); +}); + +test('a refused reply mode leaves the group with the setting it had', async (t) => { + const { team, store } = await teamFixture(t); + const bot = await writer(team); + const group = await team.createGroup(undefined, { name: 'Crew', members: [bot.id], replyMode: 'all' }); + assert.equal(group.replyMode, 'all'); + for (const replyMode of ['ALL', 'everyone', ' all', 'Admin', '']) { + await assert.rejects( + () => team.updateGroup(undefined, group.id, { replyMode }), + (error) => { assert.equal(error.code, 'invalid', `replyMode=${JSON.stringify(replyMode)}`); return true; }, + ); + } + assert.equal(store.state.groups.find((g) => g.id === group.id)?.replyMode, 'all', 'the group kept "all"'); + assert.equal(group.replyMode, 'all'); +}); + +test('the MCP group tools refuse an unknown reply mode', async (t) => { + const { team, store, rpc } = await teamFixture(t); + const bot = await writer(team); + const created = await rpc('create_group', { name: 'Crew', members: [bot.id], reply_mode: 'ALL' }); + assert.equal(created.isError, true, 'create_group failed'); + assert.match(created.content[0].text, /admin/); + assert.deepEqual(store.state.groups, []); + + const group = await team.createGroup(undefined, { name: 'Crew', members: [bot.id], replyMode: 'all' }); + const updated = await rpc('update_group', { group: group.id, reply_mode: 'ALL' }); + assert.equal(updated.isError, true, 'update_group failed'); + assert.equal(store.state.groups.find((g) => g.id === group.id)?.replyMode, 'all', 'the group kept "all"'); +}); + +test('the HTTP group entries refuse an unknown reply mode too', async (t) => { + const { post, patch } = await httpFixture(t); + const bot = (await post('/api/bots', { name: 'Writer', role: 'writer', isMain: true })).body.bot; + const created = await post('/api/groups', { name: 'Crew', members: [bot.id], replyMode: 'ALL' }); + assert.equal(created.status, 400); + assert.equal(created.body.error, 'invalid'); + + const group = (await post('/api/groups', { name: 'Crew', members: [bot.id], replyMode: 'all' })).body.group; + const updated = await patch(`/api/groups/${group.id}`, { replyMode: 'ALL' }); + assert.equal(updated.status, 400); + assert.equal(updated.body.error, 'invalid'); + assert.equal(updated.body.group, undefined, 'the group was not written to'); +}); + +// ---------------------------------------------------------------- 4. the usage range + +test('the usage range is a whole number of days, or the request is refused', async (t) => { + const { get } = await httpFixture(t); + for (const days of ['abc', '0', '91', '2.5', '-3', '', '1e999']) { + const response = await get(`/api/usage?days=${encodeURIComponent(days)}`); + assert.equal(response.status, 400, `days=${JSON.stringify(days)}`); + assert.equal(response.body.error, 'invalid', `days=${JSON.stringify(days)}`); + assert.ok(response.body.message, `days=${JSON.stringify(days)} says why`); + assert.equal(response.body.buckets, undefined, 'no empty chart is handed out'); + } +}); + +test('a legal usage range still draws its chart', async (t) => { + const { get } = await httpFixture(t); + for (const days of [1, 7, 30, 90]) { + const response = await get(`/api/usage?days=${days}`); + assert.equal(response.status, 200, `days=${days}`); + assert.equal(response.body.days, days === 1 ? 1 : days); + assert.ok(response.body.buckets.length > 0, `days=${days} has buckets`); + } + // No range at all is the default range, not a mistake. + const fallback = await get('/api/usage'); + assert.equal(fallback.status, 200); + assert.equal(fallback.body.days, 7); + assert.equal(fallback.body.buckets.length, 7); +}); + +// ---------------------------------------------------------------- 5. the messages are localised + +test('the refusals are written in the user\'s language', async (t) => { + const { team } = await teamFixture(t, 'zh'); + const bot = await writer(team); + await assert.rejects( + () => team.saveSchedule({ botId: bot.id, prompt: '周报', kind: 'weekly' }), + (error) => { + assert.equal(error.code, 'invalid'); + assert.match(error.message, /daily/); + assert.match(error.message, /interval/); + assert.match(error.message, /once/); + return true; + }, + ); + await assert.rejects( + () => team.createGroup(undefined, { name: '小组', members: [bot.id], replyMode: 'ALL' }), + (error) => { assert.equal(error.code, 'invalid'); assert.match(error.message, /admin/); return true; }, + ); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/mcp-arg-surface.test.mjs b/plugins/minimax-ai/minimax-bot/tests/mcp-arg-surface.test.mjs new file mode 100644 index 0000000..334ce25 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/mcp-arg-surface.test.mjs @@ -0,0 +1,165 @@ +// @ts-check +// What each tool accepts, pinned at the door. +// +// The catalogue declares an enum on six tools. Two different answers are correct, and this file +// pins which one each of them gets: +// +// - the tool's own message, when the tool has something to say that the schema cannot — the +// range, and what the value would have changed. A schedule kind and a group reply mode are +// like that: the tool refuses the value and the group keeps the setting it had; +// - the door's message, when the tool would otherwise read the value as a different legal one. +// A skin or a colour is like that: there is no tool-side message, so the door refuses it +// before anything runs. +// +// And a value of the wrong JSON type is refused at the door for every tool, so a call that does +// not fit the schema never reaches the tool and never changes anything. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// The marketplace keeps the regression tests outside the distributed plugin directory. +const pluginRoot = resolve(process.env.BOT_PLUGIN_ROOT ?? resolve(import.meta.dirname, '..')); +const load = async (relative) => import(pathToFileURL(join(pluginRoot, relative)).href); + +const { Store } = await load('miniapp/node/store.mjs'); +const { MemoryStore } = await load('miniapp/node/memory.mjs'); +const { Engine } = await load('miniapp/node/engine.mjs'); +const { Team } = await load('miniapp/node/team.mjs'); +const { createMcpHandler } = await load('miniapp/node/mcp.mjs'); +const { setLocale } = await load('miniapp/node/i18n.mjs'); + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'bot-arg-surface-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: () => {}, retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast: () => {} }); + const handle = createMcpHandler({ team, engine }); + const rpc = async (name, args) => { + let response; + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { response = JSON.parse(body); } }, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + return response.result; + }; + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + const chief = store.state.bots[0]; + const writer = await team.createBot(chief, { name: 'Writer', role: 'writer' }); + return { dir, store, memory, team, chief, writer, rpc }; +} + +const textOf = (result) => (result.content ?? []).map((part) => part.text).join(''); +/** The path the tool's own answer goes, not the door's. */ +const isDoorRefusal = (text) => text.includes('Fix them to match the schema'); + +test('a schedule kind the tool answers itself reaches the tool', async (t) => { + const { store, rpc } = await fixture(t); + const before = store.state.schedules.length; + const result = await rpc('create_schedule', { + caller_bot_id: store.state.bots[0].id, kind: 'weekly', time: '09:00', prompt: 'Weekly report', + }); + const text = textOf(result); + assert.equal(result.isError, true); + assert.ok(text.includes('kind must be one of: daily, interval, once'), `the tool answers: ${text}`); + assert.equal(isDoorRefusal(text), false, 'the door did not answer instead of the tool'); + assert.equal(store.state.schedules.length, before, 'nothing was scheduled'); +}); + +test('a reply mode the tool answers itself reaches the tool', async (t) => { + const { store, team, chief, writer, rpc } = await fixture(t); + const group = await team.createGroup(chief, { name: 'Review', members: [writer.id], replyMode: 'all' }); + const result = await rpc('update_group', { caller_bot_id: chief.id, group: group.id, reply_mode: 'ALL' }); + const text = textOf(result); + assert.equal(result.isError, true); + assert.ok(text.includes('reply_mode must be one of: admin, all'), `the tool answers: ${text}`); + assert.equal(isDoorRefusal(text), false, 'the door did not answer instead of the tool'); + assert.equal(store.state.groups.find((entry) => entry.id === group.id).replyMode, 'all', + 'the group keeps the setting it had'); +}); + +test('a memory scope the tool answers itself reaches the tool', async (t) => { + const { memory, chief, rpc } = await fixture(t); + for (const tool of ['remember', 'forget', 'recall']) { + const args = { + remember: { caller_bot_id: chief.id, scope: 'all', text: 'a note', key: 'k' }, + forget: { caller_bot_id: chief.id, scope: 'all', id: 'abcdef12' }, + recall: { caller_bot_id: chief.id, scope: 'all', query: 'a note' }, + }[tool]; + const result = await rpc(tool, args); + const text = textOf(result); + assert.equal(result.isError, true, `${tool} refuses an unknown scope`); + assert.ok(text.includes('Invalid memory scope "all"'), `${tool} names the scope: ${text}`); + assert.ok(text.includes('self, team, bot'), `${tool} names the range: ${text}`); + assert.equal(isDoorRefusal(text), false, `${tool} answered itself`); + } + assert.deepEqual(await memory.list('team'), [], 'no team memory was written'); + assert.deepEqual(await memory.list(chief.id), [], 'no bot memory was written'); +}); + +test('a skin or a colour is refused at the door, where nothing has run yet', async (t) => { + const { store, rpc } = await fixture(t); + const before = store.state.bots.length; + const skin = await rpc('create_bot', { name: 'Neon', role: 'writer', instructions: 'i', skin: 'neon' }); + const skinText = textOf(skin); + assert.equal(skin.isError, true); + assert.ok(skinText.includes('skin must be one of'), `the door names the range: ${skinText}`); + assert.ok(skinText.includes('(got neon)'), `the door names the value: ${skinText}`); + assert.equal(isDoorRefusal(skinText), true, 'the door answered, there is no tool-side message'); + + const color = await rpc('create_bot', { name: 'Neon', role: 'writer', instructions: 'i', color: 'neon' }); + const colorText = textOf(color); + assert.equal(color.isError, true); + assert.ok(colorText.includes('color must be one of'), `the door names the range: ${colorText}`); + assert.equal(store.state.bots.length, before, 'no Bot was created'); +}); + +test('a value of the wrong JSON type is refused at the door for every tool', async (t) => { + const { store, rpc } = await fixture(t); + const before = store.state.bots.length; + const result = await rpc('create_bot', { name: 42, role: 'writer', instructions: 'i' }); + const text = textOf(result); + assert.equal(result.isError, true); + assert.ok(text.includes('name must be of type string'), `the door names the type: ${text}`); + assert.ok(text.includes('Fix them to match the schema'), 'the door answered'); + assert.equal(store.state.bots.length, before, 'no Bot was created'); + + const enabled = await rpc('update_bot', { caller_bot_id: store.state.bots[0].id, bot: 'Writer', main: 'yes' }); + const enabledText = textOf(enabled); + assert.equal(enabled.isError, true); + assert.ok(enabledText.includes('main must be of type boolean'), `the door names the type: ${enabledText}`); +}); + +test('a legal value still goes through every one of those tools', async (t) => { + const { store, chief, rpc } = await fixture(t); + const bot = await rpc('create_bot', { + caller_bot_id: chief.id, name: 'Gold', role: 'writer', instructions: 'i', skin: 'gold', color: 'pink', + }); + assert.equal(bot.isError, undefined, textOf(bot)); + + const group = await rpc('create_group', { + caller_bot_id: chief.id, name: 'Review', members: [store.state.bots.at(-1).id], reply_mode: 'all', + }); + assert.equal(group.isError, undefined, textOf(group)); + + const schedule = await rpc('create_schedule', { + caller_bot_id: chief.id, kind: 'once', in_minutes: 30, prompt: 'Once', + }); + assert.equal(schedule.isError, undefined, textOf(schedule)); + + const remembered = await rpc('remember', { + caller_bot_id: chief.id, scope: 'self', text: 'a note', key: 'k', + }); + assert.equal(remembered.isError, undefined, textOf(remembered)); +}); diff --git a/plugins/minimax-ai/minimax-bot/tests/mcp-params.test.mjs b/plugins/minimax-ai/minimax-bot/tests/mcp-params.test.mjs new file mode 100644 index 0000000..8fc77ef --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/mcp-params.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Store } from '../miniapp/node/store.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { Team } from '../miniapp/node/team.mjs'; +import { createMcpHandler } from '../miniapp/node/mcp.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'bot-mcp-params-')); + const memory = new MemoryStore(dir); + const store = new Store(dir); + await memory.init(); + await store.init(); + setLocale('en'); + const logged = []; + const logger = { + warn: (...args) => logged.push(args), + info: () => {}, + error: () => {}, + debug: () => {}, + }; + const engine = new Engine({ store, memory, broadcast() {}, logger }); + const team = new Team({ store, memory, engine, broadcast() {} }); + const handle = createMcpHandler({ team, engine }); + const rpc = async (method, params) => { + let result; + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { result = JSON.parse(body); } }, + { jsonrpc: '2.0', id: 1, method, params }); + return result.result; + }; + t.after(async () => { + engine.dispose?.(); + await rm(dir, { recursive: true, force: true }); + }); + return { dir, memory, store, engine, team, rpc, logged }; +} + +const call = (rpc, name, args) => rpc('tools/call', { name, arguments: args }); + +test('a scope outside the values the tool declares is refused with those values, not a crash', async (t) => { + const { rpc } = await fixture(t); + for (const [name, args] of [ + ['remember', { text: 'a note', key: 'probe.note', scope: 'all' }], + ['forget', { id: 'deadbeef', scope: 'all' }], + ['recall', { scope: 'all' }], + ]) { + const result = await call(rpc, name, args); + assert.equal(result.isError, true, `${name} must refuse an unknown scope`); + const text = result.content[0].text; + assert.doesNotMatch(text, /Cannot read properties|TypeError|undefined \(reading/, `${name} must not answer with an internal fault`); + for (const allowed of ['self', 'team', 'bot']) { + assert.match(text, new RegExp(allowed), `${name} must name the allowed scope ${allowed}`); + } + } + setLocale('zh'); + const zh = await call(rpc, 'remember', { text: 'a note', key: 'probe.note', scope: 'all' }); + setLocale('en'); + assert.equal(zh.isError, true); + // The tool answers a scope: it knows the range and what the value would have changed, so its + // message is the one the model reads, not the schema's. + assert.match(zh.content[0].text, /无效的记忆范围/); + assert.match(zh.content[0].text, /self, team, bot/); + assert.doesNotMatch(zh.content[0].text, /Cannot read properties/); +}); + +test('an argument of the wrong JSON type is refused instead of guessed', async (t) => { + const { rpc } = await fixture(t); + const result = await call(rpc, 'remember', { text: 'a note', key: 'probe.note', scope: 7 }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /scope/); + assert.doesNotMatch(result.content[0].text, /Cannot read properties/); +}); + +test('a refused scope leaves every memory untouched', async (t) => { + const { rpc, memory, store } = await fixture(t); + const bot = store.state.bots[0]; + await memory.remember('team', { text: 'team note', key: 'team.note' }); + await memory.remember(bot.id, { text: 'own note', key: 'own.note' }); + const teamBefore = await memory.list('team'); + const ownBefore = await memory.list(bot.id); + for (const args of [ + { caller_bot_id: bot.id, text: 'should not be saved', key: 'probe.note', scope: 'all' }, + { text: 'should not be saved', key: 'probe.note', scope: 'all' }, + ]) { + const result = await call(rpc, 'remember', args); + assert.equal(result.isError, true); + assert.doesNotMatch(result.content[0].text, /Cannot read properties/); + } + assert.deepEqual(await memory.list('team'), teamBefore); + assert.deepEqual(await memory.list(bot.id), ownBefore); +}); + +test('a legal scope keeps its behaviour, and an unknown one is refused instead of taken for a legal one', async (t) => { + const { rpc, memory, store } = await fixture(t); + const bot = store.state.bots[0]; + const own = await call(rpc, 'remember', { caller_bot_id: bot.id, text: 'own note', key: 'own.note', scope: 'self' }); + assert.equal(own.isError, undefined); + assert.equal(JSON.parse(own.content[0].text).scope, bot.id); + assert.deepEqual((await memory.list(bot.id)).map((entry) => entry.text), ['own note']); + const shared = await call(rpc, 'remember', { caller_bot_id: bot.id, text: 'team note', key: 'team.note', scope: 'team' }); + assert.equal(shared.isError, undefined); + assert.equal(JSON.parse(shared.content[0].text).scope, 'team'); + assert.deepEqual((await memory.list('team')).map((entry) => entry.text), ['team note']); + const other = await call(rpc, 'remember', { caller_bot_id: bot.id, text: 'other note', key: 'other.note', scope: 'bot', target_bot: bot.id }); + assert.equal(other.isError, undefined); + assert.equal(JSON.parse(other.content[0].text).scope, bot.id); + const refused = await call(rpc, 'remember', { caller_bot_id: bot.id, text: 'wrong scope', key: 'wrong.note', scope: 'all' }); + assert.equal(refused.isError, true); + assert.doesNotMatch(refused.content[0].text, /Cannot read properties/); + assert.equal((await memory.list(bot.id)).length, 2); +}); + +test('the memory layer refuses an unknown scope and never dereferences a missing caller', async (t) => { + const { team } = await fixture(t); + let unknown; + try { + team.memoryScope(undefined, 'all', undefined, true); + } catch (error) { + unknown = error; + } + assert.ok(unknown, 'an unknown scope must be refused'); + assert.notEqual(unknown.name, 'TypeError'); + assert.equal(unknown.code, 'invalid'); + assert.match(unknown.message, /self/); + assert.match(unknown.message, /team/); + assert.match(unknown.message, /bot/); + let missing; + try { + team.memoryScope(undefined, 'self', undefined, true); + } catch (error) { + missing = error; + } + assert.ok(missing, 'a request without a caller must be refused'); + assert.notEqual(missing.name, 'TypeError'); + assert.equal(missing.code, 'invalid'); +}); + +test('a fault the plugin does not own is logged, not shown to the model', async (t) => { + const { rpc, store, logged } = await fixture(t); + const bot = store.state.bots[0]; + store.getBot = () => { throw new TypeError('internal detail: /srv/secret/path.js'); }; + const result = await call(rpc, 'list_team', { caller_bot_id: bot.id }); + assert.equal(result.isError, true); + assert.doesNotMatch(result.content[0].text, /internal detail|secret/); + assert.ok( + logged.some((entry) => entry[0] === 'minimax_bot.tool_failed' && /internal detail/.test(JSON.stringify(entry))), + 'the detail belongs in the log', + ); +});