fix(browser): honor image sanitization config for screenshots (#84595)

Summary:
- The branch threads `agents.defaults.imageMaxDimensionPx` into browser screenshot and labeled snapshot image results, adds regression coverage and a changelog entry, and includes small repair-pass type/lint cleanup.
- Reproducibility: yes. source-level reproduction is high confidence: current `main` calls `imageResultFromFil ...  both browser image-returning paths, while the shared sanitizer falls back to `1200px` without an override.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(browser): honor image sanitization config for screenshots
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8459…

Validation:
- ClawSweeper review passed for head c01fde7990.
- Required merge gates passed before the squash merge.

Prepared head SHA: c01fde7990
Review: https://github.com/openclaw/openclaw/pull/84595#issuecomment-4499178477

Co-authored-by: Xu Xiang <xx205@outlook.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Xu Xiang
2026-05-21 04:09:32 +08:00
committed by GitHub
parent 1a7669bc63
commit d5cc0d53b7
8 changed files with 79 additions and 26 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Dependencies: update `@openclaw/fs-safe` to `0.2.7` so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.
- Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)
- Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from `agents.defaults.model.primary`.
- Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.

View File

@@ -13,6 +13,7 @@ import {
readStringValue,
resolveBrowserConfig,
resolveProfile,
resolveRuntimeImageSanitization,
wrapExternalContent,
} from "./browser-tool.runtime.js";
import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js";
@@ -463,6 +464,7 @@ export async function executeSnapshotAction(params: {
path: snapshot.imagePath,
extraText: wrappedSnapshot,
details: safeDetails,
imageSanitization: resolveRuntimeImageSanitization(),
});
}
return {

View File

@@ -1,4 +1,13 @@
export { getRuntimeConfig } from "./sdk-config.js";
import { getRuntimeConfig } from "./sdk-config.js";
export { getRuntimeConfig };
export function resolveRuntimeImageSanitization(): { maxDimensionPx: number } | undefined {
const configured = getRuntimeConfig().agents?.defaults?.imageMaxDimensionPx;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
return undefined;
}
return { maxDimensionPx: Math.max(1, Math.floor(configured)) };
}
export {
callGatewayTool,
imageResultFromFile,

View File

@@ -123,6 +123,7 @@ const configMocks = vi.hoisted(() => ({
() => {
browser: Record<string, unknown>;
gateway?: { nodes?: { browser?: { node?: string } } };
agents?: { defaults?: { imageMaxDimensionPx?: number } };
}
>(() => ({ browser: {} })),
}));
@@ -185,6 +186,12 @@ vi.mock("./browser-tool.runtime.js", () => {
...gatewayMocks,
...sessionTabRegistryMocks,
getRuntimeConfig: configMocks.loadConfig,
resolveRuntimeImageSanitization: () => {
const configured = configMocks.loadConfig().agents?.defaults?.imageMaxDimensionPx;
return typeof configured === "number" && Number.isFinite(configured)
? { maxDimensionPx: Math.max(1, Math.floor(configured)) }
: undefined;
},
applyBrowserProxyPaths: vi.fn(),
getBrowserProfileCapabilities: (profile: Record<string, unknown>) => ({
usesChromeMcp: profile.driver === "existing-session",
@@ -715,6 +722,29 @@ describe("browser tool snapshot maxChars", () => {
expect(opts.timeoutMs).toBe(12_345);
});
it("passes configured image sanitization to screenshot image results", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
agents: { defaults: { imageMaxDimensionPx: 2000 } },
} as never);
toolCommonMocks.imageResultFromFile.mockResolvedValueOnce({
content: [{ type: "image", data: "base64", mimeType: "image/png" }],
details: { path: "/tmp/test.png" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "screenshot",
target: "host",
targetId: "tab-1",
});
const imageParams = lastMockCallArg<{
imageSanitization?: { maxDimensionPx?: number };
}>(toolCommonMocks.imageResultFromFile, 0);
expect(imageParams.imageSanitization).toEqual({ maxDimensionPx: 2000 });
});
it("passes screenshot timeoutMs through the node browser proxy", async () => {
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
@@ -1163,6 +1193,10 @@ describe("browser tool snapshot labels", () => {
registerBrowserToolAfterEachReset();
it("returns image + text when labels are requested", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
agents: { defaults: { imageMaxDimensionPx: 2000 } },
} as never);
const tool = createBrowserTool();
const imageResult = {
content: [
@@ -1188,12 +1222,14 @@ describe("browser tool snapshot labels", () => {
labels: true,
});
const imageParams = lastMockCallArg<{ path?: string; extraText?: string }>(
toolCommonMocks.imageResultFromFile,
0,
);
const imageParams = lastMockCallArg<{
path?: string;
extraText?: string;
imageSanitization?: { maxDimensionPx?: number };
}>(toolCommonMocks.imageResultFromFile, 0);
expect(imageParams.path).toBe("/tmp/snap.png");
expect(imageParams.extraText).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT");
expect(imageParams.imageSanitization).toEqual({ maxDimensionPx: 2000 });
expect(result).toEqual(imageResult);
expect(result?.content).toHaveLength(2);
expect(result?.content?.[0]).toEqual({ type: "text", text: "label text" });

View File

@@ -36,6 +36,7 @@ import {
readStringParam,
readStringValue,
resolveBrowserConfig,
resolveRuntimeImageSanitization,
resolveExistingPathsWithinRoot,
resolveNodeIdFromList,
resolveProfile,
@@ -770,6 +771,7 @@ export function createBrowserTool(opts?: {
label: "browser:screenshot",
path: result.path,
details: result,
imageSanitization: resolveRuntimeImageSanitization(),
});
}
case "navigate": {

View File

@@ -56,9 +56,9 @@ function mergeOpenRouterProviderRouting(params: {
const modelRouting = readRecord(params.modelParams?.provider);
const extraRouting = readRecord(params.extraParams.provider);
const merged = {
...(providerRouting ?? {}),
...(modelRouting ?? {}),
...(extraRouting ?? {}),
...providerRouting,
...modelRouting,
...extraRouting,
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
@@ -78,8 +78,8 @@ export function resolveOpenRouterExtraParamsForTransport(
}
return {
patch: {
...(providerConfigParams ?? {}),
...(modelParams ?? {}),
...providerConfigParams,
...modelParams,
...ctx.extraParams,
...(providerRouting ? { provider: providerRouting } : {}),
},

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { redactSensitiveStatusSummary } from "./status.summary.js";
import type { StatusSummary } from "./status.types.js";
import type { SessionStatus, StatusSummary } from "./status.types.js";
function createRecentSessionRow() {
function createRecentSessionRow(): SessionStatus {
return {
key: "main",
kind: "direct" as const,
@@ -14,6 +14,9 @@ function createRecentSessionRow() {
remainingTokens: 4,
percentUsed: 5,
model: "gpt-5",
configuredModel: "gpt-5",
selectedModel: "gpt-5",
modelSelectionReason: null,
contextTokens: 200_000,
flags: ["id:sess-1"],
};

View File

@@ -111,21 +111,21 @@ describe("readSecretFileSync", () => {
await expectSecretFileError({ setup, expectedMessage, options });
});
it("throws from the try helper for rejected files", async () => {
const file = await createSecretPath(async (dir) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await fsPromises.writeFile(target, "top-secret\n", "utf8");
await fsPromises.symlink(target, link);
return link;
});
expect(() =>
tryReadSecretFileSync(file, "Telegram bot token", { rejectSymlink: true }),
).toThrow(`Telegram bot token file at ${file} must not be a symlink.`);
});
it.each([
{
name: "returns undefined from the non-throwing helper for rejected files",
pathValue: async () =>
createSecretPath(async (dir) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await fsPromises.writeFile(target, "top-secret\n", "utf8");
await fsPromises.symlink(target, link);
return link;
}),
label: "Telegram bot token",
options: { rejectSymlink: true },
expected: undefined,
},
{
name: "returns undefined from the non-throwing helper for blank file paths",
pathValue: async () => " ",