Skip to content

Commit b2d39d4

Browse files
committed
fix: sanitize placeholder tool params
1 parent 1e95adb commit b2d39d4

14 files changed

Lines changed: 504 additions & 13 deletions

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "aitoearn-openclaw-workspace",
33
"private": true,
4-
"version": "1.0.19",
4+
"version": "1.0.20",
55
"type": "module",
66
"workspaces": [
77
"packages/shared",

packages/installer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@aitoearn/openclaw-plugin-cli",
3-
"version": "1.0.19",
3+
"version": "1.0.20",
44
"type": "module",
55
"main": "./dist/installer/src/index.js",
66
"types": "./dist/installer/src/index.d.ts",

packages/runtime/index.test.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,15 @@ interface RegisteredTool {
3939
execute: (...args: unknown[]) => Promise<unknown>;
4040
}
4141

42-
function createTool(name: string, description = `Tool ${name}`) {
42+
function createTool(
43+
name: string,
44+
description = `Tool ${name}`,
45+
inputSchema: Record<string, unknown> = { type: "object", properties: {} }
46+
) {
4347
return {
4448
name,
4549
description,
46-
inputSchema: { type: "object", properties: {} },
50+
inputSchema,
4751
};
4852
}
4953

@@ -282,6 +286,113 @@ describe("AiToEarn OpenClaw Plugin", () => {
282286
});
283287
});
284288

289+
it("should sanitize non-required placeholder values before calling MCP", async () => {
290+
toolDiscoveryMock.loadToolDefinitionsSync.mockReturnValue({
291+
source: "remote",
292+
tools: [
293+
createTool("test_tool", "Tool test_tool", {
294+
type: "object",
295+
required: ["workLink"],
296+
properties: {
297+
workLink: { type: "string" },
298+
imgUrlList: {
299+
type: "array",
300+
items: { type: "string" },
301+
},
302+
shippingAddress: {
303+
type: "object",
304+
properties: {
305+
address1: { type: "string" },
306+
city: { type: "string" },
307+
},
308+
},
309+
zero: { type: "number" },
310+
flag: { type: "boolean" },
311+
},
312+
}),
313+
],
314+
logs: [],
315+
});
316+
317+
await pluginEntry.register(mockApi as any);
318+
319+
await getRegisteredTool("test_tool").execute("test-call-id", {
320+
workLink: "https://real.example.com/work",
321+
imgUrlList: [
322+
"https://placeholder.invalid/remove-me",
323+
"https://cdn.example.com/image.jpg",
324+
],
325+
shippingAddress: {
326+
address1: " ",
327+
city: " ",
328+
},
329+
zero: 0,
330+
flag: false,
331+
note: "placeholder",
332+
});
333+
334+
expect(sharedMcpClientMock.callMcpTool).toHaveBeenCalledWith(
335+
"test-api-key",
336+
"https://test.aitoearn.ai/api",
337+
"test_tool",
338+
{
339+
workLink: "https://real.example.com/work",
340+
imgUrlList: ["https://cdn.example.com/image.jpg"],
341+
zero: 0,
342+
flag: false,
343+
}
344+
);
345+
});
346+
347+
it("should preserve placeholder values for required fields", async () => {
348+
toolDiscoveryMock.loadToolDefinitionsSync.mockReturnValue({
349+
source: "remote",
350+
tools: [
351+
createTool("test_tool", "Tool test_tool", {
352+
type: "object",
353+
required: ["imgUrlList", "payload"],
354+
properties: {
355+
imgUrlList: {
356+
type: "array",
357+
items: { type: "string" },
358+
},
359+
payload: {
360+
type: "object",
361+
required: ["workLink"],
362+
properties: {
363+
workLink: { type: "string" },
364+
caption: { type: "string" },
365+
},
366+
},
367+
},
368+
}),
369+
],
370+
logs: [],
371+
});
372+
373+
await pluginEntry.register(mockApi as any);
374+
375+
await getRegisteredTool("test_tool").execute("test-call-id", {
376+
imgUrlList: ["https://placeholder.invalid/remove-me"],
377+
payload: {
378+
workLink: "https://placeholder.invalid/remove-me",
379+
caption: " ",
380+
},
381+
});
382+
383+
expect(sharedMcpClientMock.callMcpTool).toHaveBeenCalledWith(
384+
"test-api-key",
385+
"https://test.aitoearn.ai/api",
386+
"test_tool",
387+
{
388+
imgUrlList: ["https://placeholder.invalid/remove-me"],
389+
payload: {
390+
workLink: "https://placeholder.invalid/remove-me",
391+
},
392+
}
393+
);
394+
});
395+
285396
it("should keep only China publish tools for China environment", async () => {
286397
setDiscoveredTools([
287398
"test_tool",

packages/runtime/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
applySyncToolDiscoveryLogs,
2222
loadToolDefinitionsSync,
2323
} from "./src/tool-discovery.js";
24+
import { sanitizeToolParams } from "./src/tool-params.js";
2425
import type { ToolDefinition } from "./src/tools.js";
2526

2627
const MONEY_RELATED_TOOL_NAMES = new Set([
@@ -175,7 +176,10 @@ export default definePluginEntry({
175176
config.apiKey,
176177
config.baseUrl,
177178
tool.name,
178-
params as Record<string, unknown>
179+
sanitizeToolParams(
180+
isRecord(params) ? params : {},
181+
tool.inputSchema
182+
)
179183
)) as CallToolResult;
180184
return {
181185
content: result.content.map((c) =>
@@ -267,6 +271,10 @@ function shouldRegisterTool(
267271
return policyPlatformSet.has(publishPlatform);
268272
}
269273

274+
function isRecord(value: unknown): value is Record<string, unknown> {
275+
return typeof value === "object" && value !== null && !Array.isArray(value);
276+
}
277+
270278
function describeTool(
271279
tool: ToolDefinition,
272280
environment: ReturnType<typeof resolveAiToEarnEnvironment>,

packages/runtime/openclaw.plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "aitoearn",
33
"name": "AiToEarn",
44
"description": "AiToEarn social media management tools",
5-
"version": "1.0.19",
5+
"version": "1.0.20",
66
"skills": [
77
"./skills"
88
],

packages/runtime/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@aitoearn/openclaw-plugin",
3-
"version": "1.0.19",
3+
"version": "1.0.20",
44
"type": "module",
55
"main": "./dist/runtime/index.js",
66
"types": "./dist/runtime/index.d.ts",

packages/runtime/skills/aitoearn-earn/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ description: Use this skill when the user wants a lobster that actively looks fo
113113
- `accountId``opportunityId``materialId``shippingAddress``depositAmount``sampleMode` 等都属于条件性字段;只有任务详情明确要求,或实际调用报错明确指出缺少这些字段时才补
114114
- 所有参数都遵守同一条规则:没有真实值就不要传。空字符串、只含空格的字符串、空数组、以及像 `shippingAddress` 这样展开后全为空白值的空对象,都直接省略整个字段
115115
- 示例:不要传 `materialId: " "``opportunityId: " "`,也不要传内容全为空白的 `shippingAddress`
116+
- 所有发布参数都先按真实内容类型收敛:视频内容只传视频字段,图片内容只传图片字段;不要为了“凑 schema”同时传互斥的图片和视频字段
117+
- 所有媒体 URL 和 URL 数组字段都先做真实值过滤,再决定是否传参;空字符串、空白字符串、明显占位值、`.invalid` 域名 URL、以及 `https://placeholder.invalid/remove-me` 这类占位链接,都视为不存在
118+
- `imgUrlList` 必须特别严格处理:过滤后如果没有真实图片 URL,就整个字段都不要传;`imgUrlList: [\"https://placeholder.invalid/remove-me\"]` 视为“空字段”,不是“有一张图”
119+
- 不允许为了通过发布校验伪造媒体值:不要补默认图、不要补占位图、不要补占位 URL,也不要把占位 `imgUrlList` 和真实视频字段混传
116120
- 不要因为 schema 里存在某个字段,就提前告诉用户“现在卡在这个字段”
117121
- 绝不伪造这些字段:
118122
- `taskId`
@@ -135,6 +139,8 @@ description: Use this skill when the user wants a lobster that actively looks fo
135139
- 缺默认必填主键:停下来收集,不要用别的字段硬凑
136140
- 可选字段条件不明:先按 `taskId` 主线推进,不要把 `opportunityId``materialId` 提前说成阻断项
137141
- 组织 tool 参数时,没有真实值的字段直接删掉;不要把“占位空值”传给 agent
142+
- 发布字段在清洗占位值后如果为空,直接停止并明确说“当前缺少真实媒体 URL / 当前参数仍是占位值”,不要继续重试占位值方案
143+
- 视频发布如果只拿到真实视频素材,就不要再传 `imgUrlList`;图片发布如果 `imgUrlList` 过滤后为空,也不要伪造图片参数
138144
- 缺平台账号:停在准备阶段,不要伪造发布能力
139145
- 平台在策略里但没有注册对应工具:说明“当前未提供该 publish tool”,不要说成“平台不支持”
140146
- 平台不在当前环境支持矩阵里:不要尝试走 `publishPostTo*`

packages/runtime/skills/aitoearn-earn/references/current-mcp-capability.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
- 当前 `acceptTask` 的默认理解是:公开市场接单主键为 `taskId`,其余字段按任务条件补齐
5656
- 不要因为 schema 里存在 `opportunityId``materialId`,就提前把它们当成默认阻断项
5757
- 所有条件性参数都按“没有真实值就不传”处理;不要传空字符串、空白占位值、空数组或全空对象
58+
- 所有发布工具都遵守同一条媒体规则:按真实内容类型互斥传参,不为满足 schema 混传图片和视频字段
59+
- 所有媒体 URL 字段都必须剔除占位值;`.invalid` 域名 URL 和 `https://placeholder.invalid/remove-me` 一律视为不存在,`imgUrlList` 没有真实图就整个不传
5860
- 除积分外,其余金额、收益、钱包、佣金、结算相关字段都按分为单位理解(即最小货币单位,如 cents / fen),并结合返回里的 `currency` 解释;不要擅自把它们当成主货币单位
5961
- 口头总结时必须先换算:例如 `reward: 100` + `currency: USD` 应解释为 `1 USD`
6062
- 具体执行时以当前已注册 tool 的 `description``inputSchema` 和环境结果为准

packages/runtime/skills/aitoearn-earn/references/environment-and-publishing.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,27 @@ Global 策略仅支持这些平台的 MCP 发布:
7373
- 先确认目标平台账号
7474
- 先调 `getAiToEarnEnvironment`
7575
- 先确认内容类型和素材
76+
- 先把媒体参数按内容类型收敛:视频只保留视频字段,图片只保留图片字段;不要为了满足 schema 混传互斥媒体字段
77+
- 先清洗所有媒体 URL 和 URL 数组字段,只保留真实值;空字符串、空白值、明显占位值、`.invalid` 域名 URL、以及 `https://placeholder.invalid/remove-me` 都直接删掉
78+
- `imgUrlList` 只要过滤后没有真实图片 URL,就整个字段都不要传;它不能用来塞占位图,也不能在视频发布里拿占位值凑必填
7679
- 必要时先查 `publishRestrictions`
7780
- 对 Bilibili 和 YouTube 这类需要分类的场景,先查分类工具
7881
- 任何“为了完成任务而发布”的场景,都要把 `userTaskId` 透传到 `publishPostTo*`
7982

83+
## 媒体参数规则
84+
85+
- 视频草稿、视频素材、或任务上下文明确是视频内容时:
86+
- 只传真实视频字段
87+
- 不传图片数组字段,除非 tool description 明确说该字段是视频封面且允许和视频并存
88+
- 如果 `imgUrlList` 里只有占位值,例如 `https://placeholder.invalid/remove-me`,视为“没有图片”,不是“有封面”
89+
- 图片或图文内容时:
90+
- 只传真实图片字段
91+
- `imgUrlList` 过滤后为空,就视为缺少真实图片素材,停止发布
92+
- 如果 tool / schema 同时表现出“要图片”和“视频图片不能并传”的矛盾:
93+
- 不要用占位 `imgUrlList` 重试
94+
- 不要把视频和图片混传
95+
- 直接说明这是工具侧参数冲突,当前不能靠伪造参数绕过
96+
8097
## 当前可用内容与发布支撑工具
8198

8299
### 素材与爬取

0 commit comments

Comments
 (0)