diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts index e40c697a22f..7d421d88bf5 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ResolvedBrowserProfile } from "../config.js"; +import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../constants.js"; import { resolveSnapshotPlan } from "./agent.snapshot.plan.js"; function profile(driver: "existing-session" | "openclaw"): ResolvedBrowserProfile { @@ -68,6 +69,70 @@ describe("resolveSnapshotPlan", () => { expect(plan.timeoutMs).toBe(2_147_483_647); }); + it("parses snapshot numeric query options as strict integers", () => { + const plan = resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { + limit: "25", + maxChars: "5000", + depth: "2", + timeoutMs: "12345", + }, + hasPlaywright: true, + }); + + expect(plan.limit).toBe(25); + expect(plan.resolvedMaxChars).toBe(5000); + expect(plan.depth).toBe(2); + expect(plan.timeoutMs).toBe(12345); + }); + + it("accepts structured numeric snapshot query options from proxy dispatch", () => { + const plan = resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { + limit: 25, + maxChars: 5000, + depth: 2, + timeoutMs: 12345, + }, + hasPlaywright: true, + }); + + expect(plan.limit).toBe(25); + expect(plan.resolvedMaxChars).toBe(5000); + expect(plan.depth).toBe(2); + expect(plan.timeoutMs).toBe(12345); + }); + + it("rejects loose snapshot numeric query tokens", () => { + const plan = resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { + limit: "0x10", + maxChars: "1.5", + depth: "1e0", + timeoutMs: "1000ms", + }, + hasPlaywright: true, + }); + + expect(plan.limit).toBeUndefined(); + expect(plan.resolvedMaxChars).toBe(DEFAULT_AI_SNAPSHOT_MAX_CHARS); + expect(plan.depth).toBeUndefined(); + expect(plan.timeoutMs).toBeUndefined(); + }); + + it("keeps maxChars zero as an explicit uncapped snapshot request", () => { + const plan = resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { maxChars: "0" }, + hasPlaywright: true, + }); + + expect(plan.resolvedMaxChars).toBeUndefined(); + }); + it("ignores non-positive timeoutMs values", () => { expect( resolveSnapshotPlan({ diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts index 31baea05e74..333e16bca69 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts @@ -1,7 +1,8 @@ import { - normalizeOptionalString, - readStringValue, -} from "openclaw/plugin-sdk/string-coerce-runtime"; + parseStrictNonNegativeInteger, + parseStrictPositiveInteger, +} from "openclaw/plugin-sdk/number-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedBrowserProfile } from "../config.js"; import { DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, @@ -14,7 +15,7 @@ import { shouldUsePlaywrightForScreenshot, } from "../profile-capabilities.js"; import { normalizeBrowserTimerDelayMs } from "../timer-delay.js"; -import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; +import { toBoolean, toStringOrEmpty } from "./utils.js"; type BrowserSnapshotPlan = { format: "ai" | "aria"; @@ -49,25 +50,25 @@ export function resolveSnapshotPlan(params: { explicitFormat, mode, }); - const limitRaw = readStringValue(params.query.limit); + const limit = parseStrictPositiveInteger(params.query.limit); const hasMaxChars = Object.hasOwn(params.query, "maxChars"); - const maxCharsRaw = readStringValue(params.query.maxChars); - const limit = Number.isFinite(Number(limitRaw)) ? Number(limitRaw) : undefined; - const maxChars = - Number.isFinite(Number(maxCharsRaw)) && Number(maxCharsRaw) > 0 - ? Math.floor(Number(maxCharsRaw)) - : undefined; + const maxCharsRaw = parseStrictNonNegativeInteger(params.query.maxChars); + const maxChars = maxCharsRaw !== undefined && maxCharsRaw > 0 ? maxCharsRaw : undefined; const resolvedMaxChars = format === "ai" ? hasMaxChars - ? maxChars + ? maxCharsRaw === undefined + ? mode === "efficient" + ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS + : DEFAULT_AI_SNAPSHOT_MAX_CHARS + : maxChars : mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS : DEFAULT_AI_SNAPSHOT_MAX_CHARS : undefined; const interactiveRaw = toBoolean(params.query.interactive); const compactRaw = toBoolean(params.query.compact); - const depthRaw = toNumber(params.query.depth); + const depthRaw = parseStrictNonNegativeInteger(params.query.depth); const refsModeRaw = toStringOrEmpty(params.query.refs).trim(); const refsMode: "aria" | "role" | undefined = refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined; @@ -77,11 +78,9 @@ export function resolveSnapshotPlan(params: { depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); const selectorValue = normalizeOptionalString(toStringOrEmpty(params.query.selector)); const frameSelectorValue = normalizeOptionalString(toStringOrEmpty(params.query.frame)); - const timeoutMsRaw = toNumber(params.query.timeoutMs); + const timeoutMsRaw = parseStrictPositiveInteger(params.query.timeoutMs); const timeoutMs = - timeoutMsRaw !== undefined && Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 - ? normalizeBrowserTimerDelayMs(timeoutMsRaw) - : undefined; + timeoutMsRaw !== undefined ? normalizeBrowserTimerDelayMs(timeoutMsRaw) : undefined; return { format,