fix: normalize canvas numeric params

This commit is contained in:
Peter Steinberger
2026-05-28 20:45:27 -04:00
parent 61c538e2fc
commit 61cf005437
3 changed files with 91 additions and 28 deletions
+14 -15
View File
@@ -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<T extends readonly string[]>(values: T) {
return Type.Unsafe<T[number]>({
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()),
});
+66
View File
@@ -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: {
+11 -13
View File
@@ -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<string, unknown>) {
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<string, unknown> = {};
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,