diff --git a/extensions/canvas/src/tool-schema.ts b/extensions/canvas/src/tool-schema.ts index 0af9387046ec..10475384240c 100644 --- a/extensions/canvas/src/tool-schema.ts +++ b/extensions/canvas/src/tool-schema.ts @@ -1,3 +1,9 @@ +import { + optionalFiniteNumberSchema, + optionalNonNegativeIntegerSchema, + optionalPositiveIntegerSchema, + stringEnum, +} from "openclaw/plugin-sdk/channel-actions"; import { Type } from "typebox"; export const CANVAS_ACTIONS = [ @@ -12,30 +18,23 @@ export const CANVAS_ACTIONS = [ export const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; -function stringEnum(values: T) { - return Type.Unsafe({ - type: "string", - enum: [...values], - }); -} - export const CanvasToolSchema = Type.Object({ action: stringEnum(CANVAS_ACTIONS), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), + timeoutMs: optionalPositiveIntegerSchema(), node: Type.Optional(Type.String()), target: Type.Optional(Type.String()), - x: Type.Optional(Type.Number()), - y: Type.Optional(Type.Number()), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), + x: optionalFiniteNumberSchema(), + y: optionalFiniteNumberSchema(), + width: optionalFiniteNumberSchema(), + height: optionalFiniteNumberSchema(), url: Type.Optional(Type.String()), javaScript: Type.Optional(Type.String()), outputFormat: Type.Optional(stringEnum(CANVAS_SNAPSHOT_FORMATS)), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), + maxWidth: optionalPositiveIntegerSchema(), + quality: optionalFiniteNumberSchema({ minimum: 0, maximum: 1 }), + delayMs: optionalNonNegativeIntegerSchema(), jsonl: Type.Optional(Type.String()), jsonlPath: Type.Optional(Type.String()), }); diff --git a/extensions/canvas/src/tool.test.ts b/extensions/canvas/src/tool.test.ts index 0da45934a3d0..38b9e137fe8b 100644 --- a/extensions/canvas/src/tool.test.ts +++ b/extensions/canvas/src/tool.test.ts @@ -97,6 +97,72 @@ describe("Canvas tool", () => { expect(imageResultParams?.imageSanitization).toEqual({ maxDimensionPx: 1600 }); }); + it("normalizes numeric string params before invoking node canvas commands", async () => { + mocks.callGatewayTool.mockResolvedValue({ + payload: { + format: "png", + base64: Buffer.from("not-a-real-png").toString("base64"), + }, + }); + const tool = createCanvasTool(); + + await tool.execute("tool-call-1", { + action: "present", + timeoutMs: "1500", + x: "10.5", + y: "-2", + width: "640", + height: "480", + }); + + expect(mocks.callGatewayTool).toHaveBeenLastCalledWith( + "node.invoke", + { timeoutMs: 1500 }, + expect.objectContaining({ + command: "canvas.present", + params: { + placement: { + x: 10.5, + y: -2, + width: 640, + height: 480, + }, + }, + }), + ); + + await tool.execute("tool-call-2", { + action: "snapshot", + maxWidth: "800", + quality: "0.75", + }); + + expect(mocks.callGatewayTool).toHaveBeenLastCalledWith( + "node.invoke", + {}, + expect.objectContaining({ + command: "canvas.snapshot", + params: { + format: "png", + maxWidth: 800, + quality: 0.75, + }, + }), + ); + }); + + it("rejects malformed numeric canvas params before invoking node commands", async () => { + const tool = createCanvasTool(); + + await expect( + tool.execute("tool-call-1", { + action: "snapshot", + maxWidth: "800px", + }), + ).rejects.toThrow("maxWidth must be a positive integer"); + expect(mocks.callGatewayTool).not.toHaveBeenCalled(); + }); + it("rejects node-controlled snapshot formats before creating image results", async () => { mocks.callGatewayTool.mockResolvedValue({ payload: { diff --git a/extensions/canvas/src/tool.ts b/extensions/canvas/src/tool.ts index b19752c99f6a..b4d096ea89db 100644 --- a/extensions/canvas/src/tool.ts +++ b/extensions/canvas/src/tool.ts @@ -11,6 +11,7 @@ import { jsonResult, readStringParam, } from "openclaw/plugin-sdk/channel-actions"; +import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { normalizeCanvasSnapshotFileExtension, parseCanvasSnapshotPayload } from "./cli-helpers.js"; @@ -29,7 +30,7 @@ function readGatewayCallOptions(params: Record) { return { gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, + timeoutMs: readPositiveIntegerParam(params, "timeoutMs"), }; } @@ -114,10 +115,10 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool { switch (action) { case "present": { const placement = { - x: typeof params.x === "number" ? params.x : undefined, - y: typeof params.y === "number" ? params.y : undefined, - width: typeof params.width === "number" ? params.width : undefined, - height: typeof params.height === "number" ? params.height : undefined, + x: readFiniteNumberParam(params, "x"), + y: readFiniteNumberParam(params, "y"), + width: readFiniteNumberParam(params, "width"), + height: readFiniteNumberParam(params, "height"), }; const invokeParams: Record = {}; const presentTarget = @@ -169,14 +170,11 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool { ? params.outputFormat.trim().toLowerCase() : "png"; const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; - const maxWidth = - typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) - ? params.maxWidth - : undefined; - const quality = - typeof params.quality === "number" && Number.isFinite(params.quality) - ? params.quality - : undefined; + const maxWidth = readPositiveIntegerParam(params, "maxWidth"); + const quality = readFiniteNumberParam(params, "quality", { + min: 0, + max: 1, + }); const raw = (await invoke("canvas.snapshot", { format, maxWidth,