Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,687 @@
// Diffs tests cover browser plugin behavior.
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { registerDiffsPlugin } from "./plugin.js";
import { createTempDiffRoot } from "./test-helpers.js";
const { launchMock } = vi.hoisted(() => ({
launchMock: vi.fn(),
}));
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
vi.mock("playwright-core", () => ({
chromium: {
launch: launchMock,
},
}));
function firstMockCall(
mock: { mock: { calls: Array<readonly unknown[]> } },
label: string,
): readonly unknown[] {
const call = mock.mock.calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
afterAll(() => {
vi.doUnmock("playwright-core");
vi.resetModules();
});
describe("PlaywrightDiffScreenshotter", () => {
let rootDir: string;
let outputPath: string;
let cleanupRootDir: () => Promise<void>;
beforeAll(async () => {
({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
await import("./browser.js"));
});
beforeEach(async () => {
vi.useFakeTimers();
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
outputPath = path.join(rootDir, "preview.png");
launchMock.mockReset();
await resetSharedBrowserStateForTests();
});
afterEach(async () => {
await resetSharedBrowserStateForTests();
vi.useRealTimers();
await cleanupRootDir();
});
it("reuses the same browser across renders and closes it after the idle window", async () => {
const { pages, browser, screenshotter } = await createScreenshotterHarness();
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(browser.newPage).toHaveBeenCalledTimes(2);
const firstPageParams = (
browser.newPage.mock.calls as Array<[{ deviceScaleFactor?: number }?]>
)[0]?.[0];
expect(firstPageParams?.deviceScaleFactor).toBe(2);
expect(pages).toHaveLength(2);
expect(pages[0]?.close).toHaveBeenCalledTimes(1);
expect(pages[1]?.close).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(browser.close).toHaveBeenCalledTimes(1);
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "light",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(2);
});
it("renders PDF output when format is pdf", async () => {
const { pages, screenshotter } = await createScreenshotterHarness();
const pdfPath = path.join(rootDir, "preview.pdf");
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
const pdfCall = firstMockCall(pages[0]?.pdf, "PDF render")[0] as
| Record<string, unknown>
| undefined;
if (!pdfCall) {
throw new Error("expected PDF render call");
}
expect(pdfCall).not.toHaveProperty("pageRanges");
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
});
it("fails fast when PDF render exceeds size limits", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages, {
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
});
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
const pdfPath = path.join(rootDir, "oversized.pdf");
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(0);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
it("fails fast when maxPixels is still exceeded at scale 1", async () => {
const { pages, screenshotter } = await createScreenshotterHarness();
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 1,
maxWidth: 960,
maxPixels: 10,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(pages).toHaveLength(1);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
});
describe("diffs plugin registration", () => {
it("uses live runtime tool config through the registered tool factory", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
viewerBaseUrl: "https://startup.example.com/openclaw",
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
},
},
},
} as OpenClawConfig;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
viewerBaseUrl: "https://startup.example.com/openclaw",
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {
config: {
current: () => configFile,
},
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on: vi.fn(),
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
configFile = {
...configFile,
plugins: {
entries: {
diffs: {
config: {
viewerBaseUrl: "https://live.example.com/gateway",
defaults: {
mode: "view",
theme: "dark",
background: true,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
lineSpacing: 1.6,
},
},
},
},
},
} as OpenClawConfig;
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-456",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const details = (result as { details?: Record<string, unknown> } | undefined)?.details;
const viewerPath = String(details?.viewerPath);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(String(details?.viewerUrl)).toContain("https://live.example.com/gateway");
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="dark"');
expect(String(res.body)).toContain('"backgroundEnabled":true');
expect(String(res.body)).toContain('"diffStyle":"unified"');
expect(String(res.body)).toContain('"disableLineNumbers":false');
expect(String(res.body)).toContain('"diffIndicators":"bars"');
expect(String(res.body)).toContain("--diffs-line-height: 24px;");
});
it("uses live runtime viewer-access config through the registered HTTP handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
const on = vi.fn();
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: true,
},
},
},
},
},
} as OpenClawConfig;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
security: {
allowRemoteViewer: true,
},
},
runtime: {
config: {
current: () => configFile,
},
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on,
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
expect(on).toHaveBeenCalledTimes(1);
const [hookName, beforePromptBuild] = firstMockCall(on, "plugin hook registration");
expect(hookName).toBe("before_prompt_build");
if (typeof beforePromptBuild !== "function") {
throw new Error("expected before_prompt_build callback");
}
const promptResult = await beforePromptBuild({}, {});
expect(promptResult?.prependSystemContext).toBe(
[
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"It accepts either `before` + `after` text or a unified `patch`.",
"`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
"If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
"Include `path` when you know the filename, and omit presentation overrides unless needed.",
].join("\n"),
);
expect(promptResult?.prependContext).toBeUndefined();
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
configFile = {
...configFile,
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: false,
},
},
},
},
},
} as OpenClawConfig;
const proxiedRes = createMockServerResponse();
const proxiedHandled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
headers: {
"x-forwarded-for": "203.0.113.10",
},
}),
proxiedRes,
);
expect(proxiedHandled).toBe(true);
expect(proxiedRes.statusCode).toBe(404);
});
it("fails closed for remote viewer access when the live diffs plugin entry is removed", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: true,
},
},
},
},
},
} as OpenClawConfig;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
security: {
allowRemoteViewer: true,
},
},
runtime: {
config: {
current: () => configFile,
},
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on: vi.fn(),
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-789",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
configFile = {
...configFile,
plugins: {
entries: {},
},
} as OpenClawConfig;
const proxiedRes = createMockServerResponse();
const proxiedHandled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
headers: {
"x-forwarded-for": "203.0.113.10",
},
}),
proxiedRes,
);
expect(proxiedHandled).toBe(true);
expect(proxiedRes.statusCode).toBe(404);
});
});
function createConfig(): OpenClawConfig {
return {
browser: {
executablePath: process.execPath,
},
} as OpenClawConfig;
}
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
async function createScreenshotterHarness(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages, options);
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
return { pages, browser, screenshotter };
}
function createMockBrowser(
pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}>,
options?: { boundingBox?: { x: number; y: number; width: number; height: number } },
) {
const browser = {
newPage: vi.fn(async (_options?: unknown) => {
const page = createMockPage(options);
pages.push(page);
return page;
}),
close: vi.fn(async () => {}),
on: vi.fn(),
};
return browser;
}
function createMockPage(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
const box = options?.boundingBox ?? { x: 40, y: 40, width: 640, height: 240 };
const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
});
const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
await fs.writeFile(pdfPath, "%PDF-1.7 mock");
});
return {
route: vi.fn(async () => {}),
setContent: vi.fn(async () => {}),
waitForFunction: vi.fn(async () => {}),
evaluate: vi.fn(async () => 1),
emulateMedia: vi.fn(async () => {}),
locator: vi.fn(() => ({
waitFor: vi.fn(async () => {}),
boundingBox: vi.fn(async () => box),
})),
setViewportSize: vi.fn(async () => {}),
screenshot,
pdf,
close: vi.fn(async () => {}),
};
}

View File

@@ -0,0 +1,576 @@
// Diffs plugin module implements browser behavior.
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { chromium } from "playwright-core";
import type { OpenClawConfig } from "../api.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js";
import {
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
VIEWER_ASSET_PREFIX,
getServedLanguagePackViewerAsset,
getServedViewerAsset,
} from "./viewer-assets.js";
const DEFAULT_BROWSER_IDLE_MS = 30_000;
const SHARED_BROWSER_KEY = "__default__";
const IMAGE_SIZE_LIMIT_ERROR = "Diff frame did not render within image size limits.";
const PDF_REFERENCE_PAGE_HEIGHT_PX = 1_056;
const MAX_PDF_PAGES = 50;
const LOCAL_VIEWER_BASE_HREF = "http://127.0.0.1/plugins/diffs/view/local/local";
export type DiffScreenshotter = {
screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string>;
};
type BrowserInstance = Awaited<ReturnType<typeof chromium.launch>>;
type BrowserLease = {
browser: BrowserInstance;
release(): Promise<void>;
};
type SharedBrowserState = {
browser?: BrowserInstance;
browserPromise: Promise<BrowserInstance>;
idleTimer: ReturnType<typeof setTimeout> | null;
key: string;
users: number;
};
type ExecutablePathCache = {
key: string;
valuePromise: Promise<string | undefined>;
};
let sharedBrowserState: SharedBrowserState | null = null;
let executablePathCache: ExecutablePathCache | null = null;
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
private readonly config: OpenClawConfig;
private readonly browserIdleMs: number;
constructor(params: { config: OpenClawConfig; browserIdleMs?: number }) {
this.config = params.config;
this.browserIdleMs = params.browserIdleMs ?? DEFAULT_BROWSER_IDLE_MS;
}
async screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string> {
const lease = await acquireSharedBrowser({
config: this.config,
idleMs: this.browserIdleMs,
});
let page: Awaited<ReturnType<BrowserInstance["newPage"]>> | undefined;
let currentScale = params.image.scale;
const maxRetries = 2;
try {
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
page = await lease.browser.newPage({
viewport: {
width: Math.max(Math.ceil(params.image.maxWidth + 240), 1200),
height: 900,
},
deviceScaleFactor: currentScale,
colorScheme: params.theme,
});
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
const isBaseViewerAsset = parsed.pathname.startsWith(VIEWER_ASSET_PREFIX);
const isLanguagePackViewerAsset = parsed.pathname.startsWith(
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
);
if (!isBaseViewerAsset && !isLanguagePackViewerAsset) {
await route.abort();
return;
}
const pathname = parsed.pathname;
const asset = isLanguagePackViewerAsset
? await getServedLanguagePackViewerAsset(pathname)
: await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
});
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
}
const isPdf = params.image.format === "pdf";
const padding = isPdf ? 0 : 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
if (isPdf) {
await page.emulateMedia({ media: "screen" });
await page.evaluate(() => {
const html = document.documentElement;
const body = document.body;
const frameLocal = document.querySelector(".oc-frame");
html.style.background = "transparent";
body.style.margin = "0";
body.style.padding = "0";
body.style.background = "transparent";
body.style.setProperty("-webkit-print-color-adjust", "exact");
if (frameLocal instanceof HTMLElement) {
frameLocal.style.margin = "0";
}
});
const pdfBox = await frame.boundingBox();
if (!pdfBox) {
throw new Error("Diff frame was lost before PDF render.");
}
const pdfWidth = Math.max(Math.ceil(pdfBox.width), 1);
const pdfHeight = Math.max(Math.ceil(pdfBox.height), 1);
const estimatedPixels = pdfWidth * pdfHeight;
const estimatedPages = Math.ceil(pdfHeight / PDF_REFERENCE_PAGE_HEIGHT_PX);
if (estimatedPixels > params.image.maxPixels || estimatedPages > MAX_PDF_PAGES) {
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
}
const pageForPdf = page;
await writeExternalArtifactFile({
outputPath: params.outputPath,
write: async (tempPath) => {
await pageForPdf.pdf({
path: tempPath,
width: `${pdfWidth}px`,
height: `${pdfHeight}px`,
printBackground: true,
margin: {
top: "0",
right: "0",
bottom: "0",
left: "0",
},
});
},
});
return params.outputPath;
}
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
// Raw clip in CSS px
const rawX = Math.max(box.x - padding, 0);
const rawY = Math.max(box.y - padding, 0);
const rawRight = rawX + clipWidth;
const rawBottom = rawY + clipHeight;
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
const x = Math.floor(rawX * dpr) / dpr;
const y = Math.floor(rawY * dpr) / dpr;
const right = Math.ceil(rawRight * dpr) / dpr;
const bottom = Math.ceil(rawBottom * dpr) / dpr;
const cssWidth = Math.max(right - x, 1);
const cssHeight = Math.max(bottom - y, 1);
const estimatedPixels = cssWidth * cssHeight * dpr * dpr;
if (estimatedPixels > params.image.maxPixels) {
if (currentScale > 1) {
const maxScaleForPixels = Math.sqrt(params.image.maxPixels / (cssWidth * cssHeight));
const reducedScale = Math.max(
1,
Math.round(Math.min(currentScale, maxScaleForPixels) * 100) / 100,
);
if (reducedScale < currentScale - 0.01 && attempt < maxRetries) {
await page.close().catch(() => {});
page = undefined;
currentScale = reducedScale;
continue;
}
}
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
}
const pageForScreenshot = page;
await writeExternalArtifactFile({
outputPath: params.outputPath,
write: async (tempPath) => {
await pageForScreenshot.screenshot({
path: tempPath,
type: "png",
scale: "device",
clip: {
x,
y,
width: cssWidth,
height: cssHeight,
},
});
},
});
return params.outputPath;
}
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
} catch (error) {
if (error instanceof Error && error.message === IMAGE_SIZE_LIMIT_ERROR) {
throw error;
}
const reason = formatErrorMessage(error);
throw new Error(
`Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
{ cause: error },
);
} finally {
await page?.close().catch(() => {});
await lease.release();
}
}
}
async function writeExternalArtifactFile(params: {
outputPath: string;
write: (tempPath: string) => Promise<void>;
}): Promise<void> {
const rootDir = path.dirname(params.outputPath);
await fs.mkdir(rootDir, { recursive: true });
await writeExternalFileWithinRoot({
rootDir,
path: path.basename(params.outputPath),
write: params.write,
});
}
export async function resetSharedBrowserStateForTests(): Promise<void> {
executablePathCache = null;
await closeSharedBrowser();
}
function injectBaseHref(html: string): string {
if (html.includes("<base ")) {
return html;
}
return html.replace("<head>", `<head><base href="${LOCAL_VIEWER_BASE_HREF}" />`);
}
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
const cacheKey = JSON.stringify({
configPath: config.browser?.executablePath?.trim() || "",
env: [
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH ?? "",
process.env.BROWSER_EXECUTABLE_PATH ?? "",
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "",
],
path: process.env.PATH ?? "",
});
if (executablePathCache?.key === cacheKey) {
return await executablePathCache.valuePromise;
}
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error: unknown) => {
if (executablePathCache?.valuePromise === valuePromise) {
executablePathCache = null;
}
throw error;
});
executablePathCache = {
key: cacheKey,
valuePromise,
};
return await valuePromise;
}
async function resolveBrowserExecutablePathUncached(
config: OpenClawConfig,
): Promise<string | undefined> {
const configPath = config.browser?.executablePath?.trim();
if (configPath) {
await assertExecutable(configPath, "browser.executablePath");
return configPath;
}
const envCandidates = [
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
process.env.BROWSER_EXECUTABLE_PATH,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
for (const candidate of envCandidates) {
if (await isExecutable(candidate)) {
return candidate;
}
}
for (const candidate of await collectExecutableCandidates()) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function acquireSharedBrowser(params: {
config: OpenClawConfig;
idleMs: number;
}): Promise<BrowserLease> {
const executablePath = await resolveBrowserExecutablePath(params.config);
const desiredKey = executablePath || SHARED_BROWSER_KEY;
if (sharedBrowserState && sharedBrowserState.key !== desiredKey) {
await closeSharedBrowser();
}
if (!sharedBrowserState) {
const browserPromise = chromium
.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
args: ["--disable-dev-shm-usage"],
})
.then((browser) => {
if (sharedBrowserState?.browserPromise === browserPromise) {
sharedBrowserState.browser = browser;
browser.on("disconnected", () => {
if (sharedBrowserState?.browser === browser) {
clearIdleTimer(sharedBrowserState);
sharedBrowserState = null;
}
});
}
return browser;
})
.catch((error: unknown) => {
if (sharedBrowserState?.browserPromise === browserPromise) {
sharedBrowserState = null;
}
throw error;
});
sharedBrowserState = {
browserPromise,
idleTimer: null,
key: desiredKey,
users: 0,
};
}
clearIdleTimer(sharedBrowserState);
const state = sharedBrowserState;
const browser = await state.browserPromise;
state.users += 1;
let released = false;
return {
browser,
release: async () => {
if (released) {
return;
}
released = true;
state.users = Math.max(0, state.users - 1);
if (state.users === 0) {
scheduleIdleBrowserClose(state, params.idleMs);
}
},
};
}
function scheduleIdleBrowserClose(state: SharedBrowserState, idleMs: number): void {
clearIdleTimer(state);
state.idleTimer = setTimeout(() => {
if (sharedBrowserState === state && state.users === 0) {
void closeSharedBrowser();
}
}, idleMs);
}
function clearIdleTimer(state: SharedBrowserState): void {
if (!state.idleTimer) {
return;
}
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
async function closeSharedBrowser(): Promise<void> {
const state = sharedBrowserState;
if (!state) {
return;
}
sharedBrowserState = null;
clearIdleTimer(state);
const browser = state.browser ?? (await state.browserPromise.catch(() => null));
await browser?.close().catch(() => {});
}
async function collectExecutableCandidates(): Promise<string[]> {
const candidates = new Set<string>();
for (const command of pathCommandsForPlatform()) {
const resolved = await findExecutableInPath(command);
if (resolved) {
candidates.add(resolved);
}
}
for (const candidate of commonExecutablePathsForPlatform()) {
candidates.add(candidate);
}
return [...candidates];
}
function pathCommandsForPlatform(): string[] {
if (process.platform === "win32") {
return ["chrome.exe", "msedge.exe", "brave.exe"];
}
if (process.platform === "darwin") {
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
}
return [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"msedge",
"brave-browser",
"brave",
];
}
function commonExecutablePathsForPlatform(): string[] {
if (process.platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
];
}
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
];
}
return [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/msedge",
"/usr/bin/brave-browser",
"/snap/bin/chromium",
];
}
async function findExecutableInPath(command: string): Promise<string | undefined> {
const pathValue = process.env.PATH;
if (!pathValue) {
return undefined;
}
for (const directory of pathValue.split(path.delimiter)) {
if (!directory) {
continue;
}
const candidate = path.join(directory, command);
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function assertExecutable(candidate: string, label: string): Promise<void> {
if (!(await isExecutable(candidate))) {
throw new Error(`${label} not found or not executable: ${candidate}`);
}
}
async function isExecutable(candidate: string): Promise<boolean> {
try {
await fs.access(candidate, fsConstants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,633 @@
// Diffs tests cover config plugin behavior.
import fs from "node:fs";
import os from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
validateJsonSchemaValue,
type JsonSchemaObject,
} from "openclaw/plugin-sdk/json-schema-runtime";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
DEFAULT_DIFFS_TOOL_DEFAULTS,
diffsPluginConfigSchema,
resolveDiffImageRenderOptions,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
resolveDiffsPluginViewerBaseUrl,
} from "./config.js";
import { resolveDiffsLanguagePackAvailability } from "./plugin.js";
import { ensureCuratedViewerRuntimeForTests } from "./test-helpers.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
import {
getServedLanguagePackViewerAsset,
getServedViewerAsset,
resolveViewerRuntimeFileUrl,
LANGUAGE_PACK_VIEWER_LOADER_PATH,
VIEWER_LOADER_PATH,
VIEWER_RUNTIME_PATH,
} from "./viewer-assets.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
const FULL_DEFAULTS = {
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
ttlSeconds: 21_600,
} as const;
beforeAll(async () => {
await ensureCuratedViewerRuntimeForTests();
});
function compileManifestConfigSchema() {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema: JsonSchemaObject };
return (value: unknown) =>
validateJsonSchemaValue({
cacheKey: "diffs.manifest.config.test",
schema: manifest.configSchema,
value,
applyDefaults: true,
}).ok;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function expectFields(value: unknown, fields: Record<string, unknown>) {
const record = requireRecord(value, "record");
for (const [key, expected] of Object.entries(fields)) {
expect(record[key]).toEqual(expected);
}
}
describe("resolveDiffsPluginDefaults", () => {
it("returns built-in defaults when config is missing", () => {
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
});
it("applies configured defaults from plugin config", () => {
expect(
resolveDiffsPluginDefaults({
defaults: FULL_DEFAULTS,
}),
).toEqual(FULL_DEFAULTS);
});
it("clamps and falls back for invalid line spacing and indicators", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: -5,
diffIndicators: "unknown",
},
}),
{
lineSpacing: 1,
diffIndicators: "bars",
},
);
expectFields(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: 9,
},
}),
{
lineSpacing: 3,
},
);
expectFields(
resolveDiffsPluginDefaults({
defaults: {
lineSpacing: Number.NaN,
},
}),
{
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
},
);
});
it("derives file defaults from quality preset and clamps explicit overrides", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "print",
},
}),
{
fileQuality: "print",
fileScale: 3,
fileMaxWidth: 1400,
},
);
expectFields(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "hq",
fileScale: 99,
fileMaxWidth: 99999,
},
}),
{
fileQuality: "hq",
fileScale: 4,
fileMaxWidth: 2400,
},
);
});
it("falls back to png for invalid file format defaults", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
fileFormat: "invalid" as "png",
},
}),
{
fileFormat: "png",
},
);
});
it("resolves file render format from defaults and explicit overrides", () => {
const defaults = resolveDiffsPluginDefaults({
defaults: {
fileFormat: "pdf",
},
});
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
});
it("accepts format as a config alias for fileFormat", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
format: "pdf",
},
}),
{
fileFormat: "pdf",
},
);
});
it("accepts image* config aliases for backward compatibility", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
imageFormat: "pdf",
imageQuality: "hq",
imageScale: 2.2,
imageMaxWidth: 1024,
},
}),
{
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1024,
},
);
});
it("accepts plugin-wide artifact TTL defaults", () => {
expectFields(
resolveDiffsPluginDefaults({
defaults: {
ttlSeconds: 21_600,
},
}),
{
ttlSeconds: 21_600,
},
);
expectFields(
resolveDiffsPluginDefaults({
defaults: {
ttlSeconds: 99_999,
},
}),
{
ttlSeconds: 21_600,
},
);
});
it("keeps loader-applied schema defaults from shadowing aliases and quality-derived defaults", () => {
const validate = compileManifestConfigSchema();
const aliasOnly = {
defaults: {
format: "pdf",
imageQuality: "hq",
},
};
expect(validate(aliasOnly)).toBe(true);
expectFields(resolveDiffsPluginDefaults(aliasOnly), {
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.5,
fileMaxWidth: 1200,
});
const qualityOnly = {
defaults: {
fileQuality: "hq",
},
};
expect(validate(qualityOnly)).toBe(true);
expectFields(resolveDiffsPluginDefaults(qualityOnly), {
fileQuality: "hq",
fileScale: 2.5,
fileMaxWidth: 1200,
});
});
});
describe("resolveDiffsPluginSecurity", () => {
it("defaults to local-only viewer access", () => {
expect(resolveDiffsPluginSecurity(undefined)).toEqual(DEFAULT_DIFFS_PLUGIN_SECURITY);
});
it("allows opt-in remote viewer access", () => {
expect(resolveDiffsPluginSecurity({ security: { allowRemoteViewer: true } })).toEqual({
allowRemoteViewer: true,
});
});
});
describe("resolveDiffsPluginViewerBaseUrl", () => {
it("defaults to undefined when config is missing", () => {
expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
});
it("normalizes configured viewer base URLs", () => {
expect(
resolveDiffsPluginViewerBaseUrl({
viewerBaseUrl: "https://example.com/openclaw/",
}),
).toBe("https://example.com/openclaw");
});
});
describe("diffs plugin schema surfaces", () => {
it("rejects invalid viewerBaseUrl values at manifest-validation time too", () => {
const validate = compileManifestConfigSchema();
expect(validate({ viewerBaseUrl: "javascript:alert(1)" })).toBe(false);
expect(validate({ viewerBaseUrl: "https://example.com/openclaw?x=1" })).toBe(false);
expect(validate({ viewerBaseUrl: "https://example.com/openclaw#frag" })).toBe(false);
expect(validate({ viewerBaseUrl: "https://example.com/openclaw/" })).toBe(true);
});
it("preserves defaults and security for direct safeParse callers", () => {
const parsed = requireRecord(
diffsPluginConfigSchema.safeParse?.({
viewerBaseUrl: "https://example.com/openclaw/",
defaults: {
theme: "light",
ttlSeconds: 21_600,
},
security: {
allowRemoteViewer: true,
},
}),
"parse result",
);
expect(parsed.success).toBe(true);
const data = requireRecord(parsed.data, "parse data");
expect(data.viewerBaseUrl).toBe("https://example.com/openclaw");
expectFields(data.defaults, {
fontFamily: "Fira Code",
fontSize: 15,
lineSpacing: 1.6,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
wordWrap: true,
background: true,
theme: "light",
fileFormat: "png",
fileQuality: "standard",
fileScale: 2,
fileMaxWidth: 960,
mode: "both",
ttlSeconds: 21_600,
});
expectFields(data.security, { allowRemoteViewer: true });
});
it("canonicalizes alias-driven defaults for direct safeParse callers", () => {
const parsed = requireRecord(
diffsPluginConfigSchema.safeParse?.({
defaults: {
format: "pdf",
imageQuality: "hq",
},
}),
"parse result",
);
expect(parsed.success).toBe(true);
const data = requireRecord(parsed.data, "parse data");
expectFields(data.defaults, {
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.5,
fileMaxWidth: 1200,
});
});
it("rejects invalid viewerBaseUrl config values", () => {
const parsed = requireRecord(
diffsPluginConfigSchema.safeParse?.({
viewerBaseUrl: "javascript:alert(1)",
}),
"parse result",
);
expect(parsed.success).toBe(false);
const error = requireRecord(parsed.error, "parse error");
const issues = error.issues as Array<{ path?: unknown; message?: unknown }>;
expect(issues).toHaveLength(1);
expect(issues[0]?.path).toEqual(["viewerBaseUrl"]);
expect(issues[0]?.message).toBe("viewerBaseUrl must use http or https: javascript:alert(1)");
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema?: unknown };
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
});
});
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("prefers normalized viewerBaseUrl strings too", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw/",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
it("uses the configured field name in viewerBaseUrl validation errors", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
"viewerBaseUrl must not include query/hash",
);
});
});
describe("viewer assets", () => {
it("prefers the built plugin asset layout when present", async () => {
const repoRoot = join(process.cwd(), "tmp", "diffs-viewer-assets-test-repo");
const builtRuntimePath = join(
repoRoot,
"dist",
"extensions",
"diffs",
"assets",
"viewer-runtime.js",
);
const stat = vi.fn(async (path: string) => {
if (path === builtRuntimePath) {
return { mtimeMs: 1 };
}
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
throw error;
});
const runtimeUrl = await resolveViewerRuntimeFileUrl({
baseUrl: pathToFileURL(join(repoRoot, "dist", "extensions", "diffs", "index.js")),
stat,
});
expect(fileURLToPath(runtimeUrl)).toBe(builtRuntimePath);
expect(stat).toHaveBeenCalledTimes(1);
});
it("falls back to the source asset layout when the built artifact is absent", async () => {
const repoRoot = join(process.cwd(), "tmp", "diffs-viewer-assets-test-repo");
const sourceCandidatePath = join(
repoRoot,
"extensions",
"diffs",
"src",
"assets",
"viewer-runtime.js",
);
const sourceRuntimePath = join(repoRoot, "extensions", "diffs", "assets", "viewer-runtime.js");
const stat = vi.fn(async (path: string) => {
if (path === sourceRuntimePath) {
return { mtimeMs: 1 };
}
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
throw error;
});
const runtimeUrl = await resolveViewerRuntimeFileUrl({
baseUrl: pathToFileURL(join(repoRoot, "extensions", "diffs", "src", "viewer-assets.js")),
stat,
});
expect(fileURLToPath(runtimeUrl)).toBe(sourceRuntimePath);
expect(stat).toHaveBeenNthCalledWith(1, sourceCandidatePath);
expect(stat).toHaveBeenNthCalledWith(2, sourceRuntimePath);
});
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`./viewer-runtime.js?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
expect(String(runtime?.body)).toContain('style.width="24px"');
expect(String(runtime?.body)).toContain('style.gap="6px"');
});
it("serves the optional language-pack loader only when its generated runtime is present", async () => {
const loader = await getServedLanguagePackViewerAsset(LANGUAGE_PACK_VIEWER_LOADER_PATH);
if (!loader) {
expect(loader).toBeNull();
return;
}
expect(loader.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader.body)).toContain(`./viewer-runtime.js?v=`);
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});
describe("resolveDiffsLanguagePackAvailability", () => {
it.each(["assets", "dist/assets"])(
"requires both the sibling language-pack manifest and generated runtime asset in %s",
(assetDir) => {
const root = fs.mkdtempSync(join(os.tmpdir(), "openclaw-diffs-language-pack-"));
try {
const diffsRoot = join(root, "diffs");
const languagePackRoot = join(root, "diffs-language-pack");
fs.mkdirSync(diffsRoot, { recursive: true });
fs.mkdirSync(languagePackRoot, { recursive: true });
fs.writeFileSync(
join(languagePackRoot, "openclaw.plugin.json"),
'{"id":"diffs-language-pack"}\n',
);
const api = {
rootDir: diffsRoot,
config: { plugins: {} },
runtime: { config: { current: () => ({ plugins: {} }) } },
} as Parameters<typeof resolveDiffsLanguagePackAvailability>[0];
expect(resolveDiffsLanguagePackAvailability(api)).toBe(false);
fs.mkdirSync(join(languagePackRoot, assetDir), { recursive: true });
fs.writeFileSync(join(languagePackRoot, assetDir, "viewer-runtime.js"), "export {};\n");
expect(resolveDiffsLanguagePackAvailability(api)).toBe(true);
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
},
);
});
describe("parseViewerPayloadJson", () => {
function buildValidPayload(): Record<string, unknown> {
return {
prerenderedHTML: "<div>ok</div>",
langs: ["text"],
oldFile: {
name: "README.md",
contents: "before",
},
newFile: {
name: "README.md",
contents: "after",
},
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: ":host{}",
},
};
}
it("accepts valid payload JSON", () => {
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
expect(parsed.options.diffStyle).toBe("unified");
expect(parsed.options.diffIndicators).toBe("bars");
});
it("rejects payloads with invalid shape", () => {
const broken = buildValidPayload();
broken.options = {
...(broken.options as Record<string, unknown>),
diffIndicators: "invalid",
};
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
"Diff payload has invalid shape.",
);
});
it("rejects invalid JSON", () => {
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
});
});

View File

@@ -0,0 +1,444 @@
// Diffs helper module supports config behavior.
import { mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
import { z } from "zod";
import type { OpenClawPluginConfigSchema } from "../api.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_INDICATORS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffFileDefaults,
type DiffImageQualityPreset,
type DiffIndicators,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffTheme,
type DiffToolDefaults,
} from "./types.js";
import { normalizeViewerBaseUrl } from "./url.js";
type DiffsPluginConfig = {
viewerBaseUrl?: string;
defaults?: {
fontFamily?: string;
fontSize?: number;
lineSpacing?: number;
layout?: DiffLayout;
showLineNumbers?: boolean;
diffIndicators?: DiffIndicators;
wordWrap?: boolean;
background?: boolean;
theme?: DiffTheme;
fileFormat?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
/** @deprecated Use fileFormat. */
format?: DiffOutputFormat;
/** @deprecated Use fileFormat. */
imageFormat?: DiffOutputFormat;
/** @deprecated Use fileQuality. */
imageQuality?: DiffImageQualityPreset;
/** @deprecated Use fileScale. */
imageScale?: number;
/** @deprecated Use fileMaxWidth. */
imageMaxWidth?: number;
mode?: DiffMode;
ttlSeconds?: number;
};
security?: {
allowRemoteViewer?: boolean;
};
};
const DEFAULT_IMAGE_QUALITY_PROFILES = {
standard: {
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
hq: {
scale: 2.5,
maxWidth: 1200,
maxPixels: 14_000_000,
},
print: {
scale: 3,
maxWidth: 1400,
maxPixels: 24_000_000,
},
} as const satisfies Record<
DiffImageQualityPreset,
{ scale: number; maxWidth: number; maxPixels: number }
>;
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
fontFamily: "Fira Code",
fontSize: 15,
lineSpacing: 1.6,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: DEFAULT_IMAGE_QUALITY_PROFILES.standard.scale,
fileMaxWidth: DEFAULT_IMAGE_QUALITY_PROFILES.standard.maxWidth,
mode: "both",
ttlSeconds: 1800,
};
type DiffsPluginSecurityConfig = {
allowRemoteViewer: boolean;
};
export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
allowRemoteViewer: false,
};
const VIEWER_BASE_URL_JSON_SCHEMA = {
type: "string",
format: "uri",
pattern: "^[Hh][Tt][Tt][Pp][Ss]?://",
not: {
pattern: "[?#]",
},
} as const satisfies Record<string, unknown>;
const DiffsPluginJsonSchemaSource = z.strictObject({
viewerBaseUrl: z
.string()
.superRefine((value, ctx) => {
try {
normalizeViewerBaseUrl(value, "viewerBaseUrl");
} catch (error) {
ctx.addIssue({
code: "custom",
message: error instanceof Error ? error.message : "Invalid viewerBaseUrl",
});
}
})
.optional(),
defaults: z
.strictObject({
fontFamily: z.string().default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily).optional(),
fontSize: z.number().min(10).max(24).default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize).optional(),
lineSpacing: z
.number()
.min(1)
.max(3)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing)
.optional(),
layout: z.enum(DIFF_LAYOUTS).default(DEFAULT_DIFFS_TOOL_DEFAULTS.layout).optional(),
showLineNumbers: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.showLineNumbers).optional(),
diffIndicators: z
.enum(DIFF_INDICATORS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators)
.optional(),
wordWrap: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap).optional(),
background: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.background).optional(),
theme: z.enum(DIFF_THEMES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.theme).optional(),
fileFormat: z
.enum(DIFF_OUTPUT_FORMATS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat)
.optional(),
format: z.enum(DIFF_OUTPUT_FORMATS).optional().describe("Deprecated alias for fileFormat."),
fileQuality: z
.enum(DIFF_IMAGE_QUALITY_PRESETS)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality)
.optional(),
fileScale: z.number().min(1).max(4).optional(),
fileMaxWidth: z.number().min(640).max(2400).optional(),
imageFormat: z
.enum(DIFF_OUTPUT_FORMATS)
.optional()
.describe("Deprecated alias for fileFormat."),
imageQuality: z
.enum(DIFF_IMAGE_QUALITY_PRESETS)
.optional()
.describe("Deprecated alias for fileQuality."),
imageScale: z.number().min(1).max(4).optional().describe("Deprecated alias for fileScale."),
imageMaxWidth: z
.number()
.min(640)
.max(2400)
.optional()
.describe("Deprecated alias for fileMaxWidth."),
mode: z.enum(DIFF_MODES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.mode).optional(),
ttlSeconds: z
.number()
.min(1)
.max(21_600)
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds)
.optional(),
})
.optional(),
security: z
.strictObject({
allowRemoteViewer: z
.boolean()
.default(DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer)
.optional(),
})
.optional(),
});
const diffsPluginConfigSchemaBase = buildPluginConfigSchema(DiffsPluginJsonSchemaSource, {
safeParse(value: unknown) {
if (value === undefined) {
return { success: true, data: undefined };
}
const result = DiffsPluginJsonSchemaSource.safeParse(value);
if (result.success) {
return {
success: true,
data: buildDiffsPluginConfigShape(result.data as DiffsPluginConfig),
};
}
return {
success: false,
error: {
issues: mapPluginConfigIssues(result.error.issues),
},
};
},
});
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
...diffsPluginConfigSchemaBase,
jsonSchema: {
...diffsPluginConfigSchemaBase.jsonSchema,
properties: {
...(diffsPluginConfigSchemaBase.jsonSchema as { properties?: Record<string, unknown> })
.properties,
viewerBaseUrl: VIEWER_BASE_URL_JSON_SCHEMA,
},
},
};
function resolveConfiguredValue<T>(options: {
primary: T | undefined;
aliases: Array<T | undefined>;
schemaDefault?: T;
}): T | undefined {
const alias = options.aliases.find((value): value is T => value !== undefined);
if (alias !== undefined && options.primary === options.schemaDefault) {
return alias;
}
return options.primary ?? alias;
}
function buildDiffsPluginConfigShape(config: DiffsPluginConfig): DiffsPluginConfig {
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(config);
return {
...(viewerBaseUrl !== undefined ? { viewerBaseUrl } : {}),
...(config.defaults !== undefined ? { defaults: resolveDiffsPluginDefaults(config) } : {}),
...(config.security !== undefined ? { security: resolveDiffsPluginSecurity(config) } : {}),
};
}
export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
const defaults = (config as DiffsPluginConfig).defaults;
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
const fileQuality = normalizeFileQuality(
resolveConfiguredValue({
primary: defaults.fileQuality,
aliases: [defaults.imageQuality],
schemaDefault: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality,
}),
);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[fileQuality];
const fileFormat = resolveConfiguredValue({
primary: defaults.fileFormat,
aliases: [defaults.imageFormat, defaults.format],
schemaDefault: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat,
});
const fileScale = resolveConfiguredValue({
primary: defaults.fileScale,
aliases: [defaults.imageScale],
});
const fileMaxWidth = resolveConfiguredValue({
primary: defaults.fileMaxWidth,
aliases: [defaults.imageMaxWidth],
});
return {
fontFamily: normalizeFontFamily(defaults.fontFamily),
fontSize: normalizeDiffFontSize(defaults.fontSize),
lineSpacing: normalizeDiffLineSpacing(defaults.lineSpacing),
layout: normalizeLayout(defaults.layout),
showLineNumbers: defaults.showLineNumbers !== false,
diffIndicators: normalizeDiffIndicators(defaults.diffIndicators),
wordWrap: defaults.wordWrap !== false,
background: defaults.background !== false,
theme: normalizeTheme(defaults.theme),
fileFormat: normalizeFileFormat(fileFormat),
fileQuality,
fileScale: normalizeFileScale(fileScale, profile.scale),
fileMaxWidth: normalizeFileMaxWidth(fileMaxWidth, profile.maxWidth),
mode: normalizeMode(defaults.mode),
ttlSeconds: normalizeTtlSeconds(defaults.ttlSeconds),
};
}
export function resolveDiffsPluginSecurity(config: unknown): DiffsPluginSecurityConfig {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
}
const security = (config as DiffsPluginConfig).security;
if (!security || typeof security !== "object" || Array.isArray(security)) {
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
}
return {
allowRemoteViewer: security.allowRemoteViewer === true,
};
}
export function resolveDiffsPluginViewerBaseUrl(config: unknown): string | undefined {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return undefined;
}
const viewerBaseUrl = (config as DiffsPluginConfig).viewerBaseUrl;
if (typeof viewerBaseUrl !== "string") {
return undefined;
}
const normalized = viewerBaseUrl.trim();
return normalized ? normalizeViewerBaseUrl(normalized) : undefined;
}
function normalizeFontFamily(fontFamily?: string): string {
const normalized = fontFamily?.trim();
return normalized || DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily;
}
export function normalizeDiffFontSize(fontSize?: number): number {
if (fontSize === undefined || !Number.isFinite(fontSize)) {
return DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize;
}
const rounded = Math.floor(fontSize);
return Math.min(Math.max(rounded, 10), 24);
}
export function normalizeDiffLineSpacing(lineSpacing?: number): number {
if (lineSpacing === undefined || !Number.isFinite(lineSpacing)) {
return DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing;
}
return Math.min(Math.max(lineSpacing, 1), 3);
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : DEFAULT_DIFFS_TOOL_DEFAULTS.layout;
}
function normalizeDiffIndicators(diffIndicators?: DiffIndicators): DiffIndicators {
return diffIndicators && DIFF_INDICATORS.includes(diffIndicators)
? diffIndicators
: DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators;
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
}
function normalizeFileFormat(fileFormat?: DiffOutputFormat): DiffOutputFormat {
return fileFormat && DIFF_OUTPUT_FORMATS.includes(fileFormat)
? fileFormat
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat;
}
function normalizeFileQuality(fileQuality?: DiffImageQualityPreset): DiffImageQualityPreset {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality)
? fileQuality
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality;
}
function normalizeFileScale(fileScale: number | undefined, fallback: number): number {
if (fileScale === undefined || !Number.isFinite(fileScale)) {
return fallback;
}
const rounded = Math.round(fileScale * 100) / 100;
return Math.min(Math.max(rounded, 1), 4);
}
function normalizeFileMaxWidth(fileMaxWidth: number | undefined, fallback: number): number {
if (fileMaxWidth === undefined || !Number.isFinite(fileMaxWidth)) {
return fallback;
}
const rounded = Math.round(fileMaxWidth);
return Math.min(Math.max(rounded, 640), 2400);
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
}
function normalizeTtlSeconds(ttlSeconds?: number): number {
if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) {
return DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds;
}
const rounded = Math.floor(ttlSeconds);
return Math.min(Math.max(rounded, 1), 21_600);
}
export function resolveDiffImageRenderOptions(params: {
defaults: DiffFileDefaults;
fileFormat?: DiffOutputFormat;
format?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
}): {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
} {
const format = normalizeFileFormat(
params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat,
);
const qualityOverrideProvided =
params.fileQuality !== undefined || params.imageQuality !== undefined;
const qualityPreset = normalizeFileQuality(
params.fileQuality ?? params.imageQuality ?? params.defaults.fileQuality,
);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[qualityPreset];
const scale = normalizeFileScale(
params.fileScale ?? params.imageScale,
qualityOverrideProvided ? profile.scale : params.defaults.fileScale,
);
const maxWidth = normalizeFileMaxWidth(
params.fileMaxWidth ?? params.imageMaxWidth,
qualityOverrideProvided ? profile.maxWidth : params.defaults.fileMaxWidth,
);
return {
format,
qualityPreset,
scale,
maxWidth,
maxPixels: profile.maxPixels,
};
}

View File

@@ -0,0 +1,325 @@
// Diffs plugin module implements http behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PluginLogger } from "../api.js";
import { resolveRequestClientIp } from "../runtime-api.js";
import type { DiffArtifactStore } from "./store.js";
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const VIEW_PREFIX = "/plugins/diffs/view/";
const VIEWER_MAX_FAILURES_PER_WINDOW = 40;
const VIEWER_FAILURE_WINDOW_MS = 60_000;
const VIEWER_LOCKOUT_MS = 60_000;
const VIEWER_LIMITER_MAX_KEYS = 2_048;
const VIEWER_CONTENT_SECURITY_POLICY = [
"default-src 'none'",
"script-src 'self'",
"style-src 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
"object-src 'none'",
].join("; ");
export function createDiffsHttpHandler(params: {
store: DiffArtifactStore;
logger?: PluginLogger;
allowRemoteViewer?: boolean;
trustedProxies?: readonly string[];
allowRealIpFallback?: boolean;
resolveAccessConfig?: () => {
allowRemoteViewer?: boolean;
trustedProxies?: readonly string[];
allowRealIpFallback?: boolean;
};
}) {
const viewerFailureLimiter = new ViewerFailureLimiter();
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url);
if (!parsed) {
return false;
}
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
return await serveAsset(req, res, parsed.pathname, params.logger);
}
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
return false;
}
const accessConfig = params.resolveAccessConfig?.() ?? {
allowRemoteViewer: params.allowRemoteViewer,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
};
const access = resolveViewerAccess(req, {
trustedProxies: accessConfig.trustedProxies,
allowRealIpFallback: accessConfig.allowRealIpFallback,
});
if (!access.localRequest && accessConfig.allowRemoteViewer !== true) {
respondText(res, 404, "Diff not found");
return true;
}
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
if (!access.localRequest) {
const throttled = viewerFailureLimiter.check(access.remoteKey);
if (!throttled.allowed) {
res.statusCode = 429;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(throttled.retryAfterMs / 1000))));
res.end("Too Many Requests");
return true;
}
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[3];
const token = pathParts[4];
if (
!id ||
!token ||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
) {
recordRemoteFailure(viewerFailureLimiter, access);
respondText(res, 404, "Diff not found");
return true;
}
const artifact = await params.store.getArtifact(id, token);
if (!artifact) {
recordRemoteFailure(viewerFailureLimiter, access);
respondText(res, 404, "Diff not found or expired");
return true;
}
try {
const html = await params.store.readHtml(id);
resetRemoteFailures(viewerFailureLimiter, access);
res.statusCode = 200;
setSharedHeaders(res, "text/html; charset=utf-8");
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
if (req.method === "HEAD") {
res.end();
} else {
res.end(html);
}
return true;
} catch (error) {
recordRemoteFailure(viewerFailureLimiter, access);
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
respondText(res, 500, "Failed to load diff");
return true;
}
};
}
function parseRequestUrl(rawUrl?: string): URL | null {
if (!rawUrl) {
return null;
}
try {
return new URL(rawUrl, "http://127.0.0.1");
} catch {
return null;
}
}
async function serveAsset(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
logger?: PluginLogger,
): Promise<boolean> {
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
try {
const asset = await getServedViewerAsset(pathname);
if (!asset) {
respondText(res, 404, "Asset not found");
return true;
}
res.statusCode = 200;
setSharedHeaders(res, asset.contentType);
if (req.method === "HEAD") {
res.end();
} else {
res.end(asset.body);
}
return true;
} catch (error) {
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
respondText(res, 500, "Failed to load asset");
return true;
}
}
function respondText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.end(body);
}
function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("cache-control", "no-store, max-age=0");
res.setHeader("content-type", contentType);
res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer");
}
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
const normalized = normalizeLowercaseStringOrEmpty(remoteAddress);
if (!normalized) {
return "unknown";
}
return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
}
function isLoopbackClientIp(clientIp: string): boolean {
return clientIp === "127.0.0.1" || clientIp === "::1";
}
function hasProxyForwardingHints(req: IncomingMessage): boolean {
const headers = req.headers ?? {};
return Boolean(
headers["x-forwarded-for"] ||
headers["x-real-ip"] ||
headers.forwarded ||
headers["x-forwarded-host"] ||
headers["x-forwarded-proto"],
);
}
function resolveViewerAccess(
req: IncomingMessage,
params: {
trustedProxies?: readonly string[];
allowRealIpFallback?: boolean;
},
): {
remoteKey: string;
localRequest: boolean;
} {
const proxyHintsPresent = hasProxyForwardingHints(req);
const clientIp =
proxyHintsPresent || (params.trustedProxies?.length ?? 0) > 0
? // Reuse gateway proxy trust rules and fail closed when a trusted proxy hop
// does not provide usable client-origin headers.
resolveRequestClientIp(
req,
params.trustedProxies ? [...params.trustedProxies] : undefined,
params.allowRealIpFallback === true,
)
: req.socket?.remoteAddress;
const remoteKey = normalizeRemoteClientKey(clientIp ?? req.socket?.remoteAddress);
const localRequest =
!proxyHintsPresent && typeof clientIp === "string" && isLoopbackClientIp(remoteKey);
return { remoteKey, localRequest };
}
function recordRemoteFailure(
limiter: ViewerFailureLimiter,
access: { remoteKey: string; localRequest: boolean },
): void {
if (!access.localRequest) {
limiter.recordFailure(access.remoteKey);
}
}
function resetRemoteFailures(
limiter: ViewerFailureLimiter,
access: { remoteKey: string; localRequest: boolean },
): void {
if (!access.localRequest) {
limiter.reset(access.remoteKey);
}
}
type RateLimitCheckResult = {
allowed: boolean;
retryAfterMs: number;
};
type ViewerFailureState = {
windowStartMs: number;
failures: number;
lockUntilMs: number;
};
class ViewerFailureLimiter {
private readonly failures = new Map<string, ViewerFailureState>();
check(key: string): RateLimitCheckResult {
this.prune();
const state = this.failures.get(key);
if (!state) {
return { allowed: true, retryAfterMs: 0 };
}
const now = Date.now();
if (state.lockUntilMs > now) {
return { allowed: false, retryAfterMs: state.lockUntilMs - now };
}
if (now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
this.failures.delete(key);
return { allowed: true, retryAfterMs: 0 };
}
return { allowed: true, retryAfterMs: 0 };
}
recordFailure(key: string): void {
this.prune();
const now = Date.now();
const current = this.failures.get(key);
const next =
!current || now - current.windowStartMs >= VIEWER_FAILURE_WINDOW_MS
? {
windowStartMs: now,
failures: 1,
lockUntilMs: 0,
}
: {
...current,
failures: current.failures + 1,
};
if (next.failures >= VIEWER_MAX_FAILURES_PER_WINDOW) {
next.lockUntilMs = now + VIEWER_LOCKOUT_MS;
}
this.failures.set(key, next);
}
reset(key: string): void {
this.failures.delete(key);
}
private prune(): void {
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
return;
}
const now = Date.now();
for (const [key, state] of this.failures) {
if (state.lockUntilMs <= now && now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
this.failures.delete(key);
}
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
return;
}
}
if (this.failures.size >= VIEWER_LIMITER_MAX_KEYS) {
this.failures.clear();
}
}
}

View File

@@ -0,0 +1,245 @@
// Diffs tests cover language hints plugin behavior.
import type { FileDiffMetadata } from "@pierre/diffs";
import { describe, expect, it } from "vitest";
import {
normalizeDiffViewerPayloadLanguages,
normalizeSupportedLanguageHint,
} from "./language-hints.js";
async function normalizeHints(values: readonly string[], options = {}) {
return await Promise.all(values.map((value) => normalizeSupportedLanguageHint(value, options)));
}
describe("normalizeSupportedLanguageHint", () => {
it("keeps supported languages", async () => {
await expect(normalizeHints(["typescript", "cpp", "text"])).resolves.toEqual([
"typescript",
"cpp",
"text",
]);
});
it("normalizes common aliases to base viewer languages", async () => {
await expect(
normalizeHints(["ts", "c++", "c#", "bash", "dockerfile", "rb", "kt", "ps1"]),
).resolves.toEqual([
"typescript",
"cpp",
"csharp",
"sh",
"docker",
"ruby",
"kotlin",
"powershell",
]);
});
it("keeps mainstream languages in the base viewer without the language pack", async () => {
await expect(
normalizeHints([
"ruby",
"swift",
"kotlin",
"r",
"dart",
"lua",
"powershell",
"xml",
"toml",
]),
).resolves.toEqual([
"ruby",
"swift",
"kotlin",
"r",
"dart",
"lua",
"powershell",
"xml",
"toml",
]);
});
it("drops uncommon languages without the language pack", async () => {
await expect(normalizeSupportedLanguageHint("abap")).resolves.toBeUndefined();
});
it("keeps uncommon languages when the language pack is available", async () => {
await expect(
normalizeSupportedLanguageHint("abap", { languagePackAvailable: true }),
).resolves.toBe("abap");
});
it("drops invalid languages", async () => {
await expect(normalizeSupportedLanguageHint("not-a-real-language")).resolves.toBeUndefined();
});
it("keeps valid languages when invalid hints are mixed in", async () => {
await expect(normalizeHints(["typescript", "not-a-real-language"])).resolves.toEqual([
"typescript",
undefined,
]);
});
});
describe("normalizeDiffViewerPayloadLanguages", () => {
it("rewrites stale patch payload language overrides to plain text", async () => {
const result = await normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["not-a-real-language" as never],
fileDiff: {
name: "foo.txt",
lang: "not-a-real-language" as never,
} as unknown as FileDiffMetadata,
});
expect(result.langs).toEqual(["text"]);
expect(result.fileDiff?.lang).toBe("text");
});
it("keeps valid hydrated languages and only downgrades invalid sides", async () => {
const result = await normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "split",
diffIndicators: "classic",
disableLineNumbers: true,
expandUnchanged: true,
themeType: "light",
backgroundEnabled: false,
overflow: "scroll",
unsafeCSS: "",
},
langs: ["typescript", "not-a-real-language" as never],
oldFile: {
name: "before.unknown",
contents: "before",
lang: "not-a-real-language" as never,
},
newFile: {
name: "after.ts",
contents: "after",
lang: "typescript",
},
});
expect(result.langs).toEqual(["typescript", "text"]);
expect(result.oldFile?.lang).toBe("text");
expect(result.newFile?.lang).toBe("typescript");
});
it("keeps uncommon hydrated languages when the language pack is available", async () => {
const result = await normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["abap" as never],
fileDiff: {
name: "demo.abap",
lang: "abap" as never,
} as unknown as FileDiffMetadata,
},
{ languagePackAvailable: true },
);
expect(result.langs).toEqual(["abap"]);
expect(result.fileDiff?.lang).toBe("abap");
});
it("rewrites blank explicit language overrides to plain text", async () => {
const result = await normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: [" " as never],
oldFile: {
name: "before.unknown",
contents: "before",
lang: " " as never,
},
newFile: {
name: "after.txt",
contents: "after",
},
});
expect(result.langs).toEqual(["text"]);
expect(result.oldFile?.lang).toBe("text");
});
it("does not inject text when a valid file language is the only supported hint", async () => {
const result = await normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: [],
oldFile: {
name: "before.ts",
contents: "before",
lang: "typescript",
},
newFile: {
name: "after.ts",
contents: "after",
lang: "typescript",
},
});
expect(result.langs).toEqual(["typescript"]);
});
});

View File

@@ -0,0 +1,151 @@
// Diffs plugin module implements language hints behavior.
import { resolveLanguage } from "@pierre/diffs";
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import {
bundledLanguagesBase,
bundledLanguagesInfo,
getBundledLanguageAliases,
} from "./shiki-curated-languages.js";
import type { DiffViewerPayload } from "./types.js";
export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
...Object.keys(bundledLanguagesBase),
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(
bundledLanguagesInfo.flatMap((language) =>
getBundledLanguageAliases(language).map((alias) => [alias, language.id as SupportedLanguages]),
),
);
type DiffPayloadFile = FileContents | FileDiffMetadata;
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export async function normalizeSupportedLanguageHint(
value?: string,
options: { languagePackAvailable?: boolean } = {},
): Promise<SupportedLanguages | undefined> {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return undefined;
}
const baseAlias = BASE_LANGUAGE_ALIASES.get(normalized);
if (baseAlias) {
return baseAlias;
}
if (BASE_LANGUAGE_HINTS.has(normalized as SupportedLanguages)) {
return normalized as SupportedLanguages;
}
if (!options.languagePackAvailable) {
return undefined;
}
try {
await resolveLanguage(normalized as Exclude<SupportedLanguages, "text" | "ansi">);
return normalized as SupportedLanguages;
} catch {
return undefined;
}
}
async function normalizeSupportedLanguageHints(
values: Iterable<string>,
options: { fallbackToText: boolean; languagePackAvailable?: boolean },
): Promise<SupportedLanguages[]> {
const supported = new Set<SupportedLanguages>();
for (const value of values) {
const normalized = await normalizeSupportedLanguageHint(value, options);
if (!normalized) {
continue;
}
supported.add(normalized);
}
if (options.fallbackToText && supported.size === 0) {
supported.add("text");
}
return [...supported];
}
export function collectDiffPayloadLanguageHints(payload: {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
}): SupportedLanguages[] {
const langs = new Set<SupportedLanguages>();
if (payload.fileDiff?.lang) {
langs.add(payload.fileDiff.lang);
}
if (payload.oldFile?.lang) {
langs.add(payload.oldFile.lang);
}
if (payload.newFile?.lang) {
langs.add(payload.newFile.lang);
}
return [...langs];
}
async function normalizeDiffPayloadFileLanguage(
file: DiffPayloadFile | undefined,
options: { languagePackAvailable?: boolean },
): Promise<DiffPayloadFile | undefined> {
if (!file) {
return undefined;
}
if (typeof file.lang !== "string") {
return file;
}
const normalized = await normalizeSupportedLanguageHint(file.lang, options);
if (file.lang === normalized) {
return file;
}
if (!normalized) {
return {
...file,
lang: "text",
};
}
return {
...file,
lang: normalized,
};
}
export async function normalizeDiffViewerPayloadLanguages(
payload: DiffViewerPayload,
options: { languagePackAvailable?: boolean } = {},
): Promise<DiffViewerPayload> {
const [fileDiff, oldFile, newFile, payloadLangs] = await Promise.all([
normalizeDiffPayloadFileLanguage(payload.fileDiff, options) as Promise<
FileDiffMetadata | undefined
>,
normalizeDiffPayloadFileLanguage(payload.oldFile, options) as Promise<FileContents | undefined>,
normalizeDiffPayloadFileLanguage(payload.newFile, options) as Promise<FileContents | undefined>,
normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false, ...options }),
]);
const langs = new Set<SupportedLanguages>(payloadLangs);
for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
langs.add(lang);
}
if (langs.size === 0) {
langs.add("text");
}
return {
...payload,
fileDiff,
oldFile,
newFile,
langs: [...langs],
};
}
export function isBaseDiffViewerLanguage(lang: string): boolean {
return BASE_LANGUAGE_HINTS.has(lang as SupportedLanguages);
}

View File

@@ -0,0 +1,17 @@
// Diffs tests cover manifest plugin behavior.
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type DiffsPackageManifest = {
dependencies?: Record<string, string>;
};
describe("diffs package manifest", () => {
it("keeps runtime dependencies in the package manifest", () => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as DiffsPackageManifest;
expect(packageJson.dependencies).toHaveProperty("@pierre/diffs");
});
});

View File

@@ -0,0 +1,111 @@
// Diffs plugin module implements plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
resolvePreferredOpenClawTmpDir,
type OpenClawConfig,
type OpenClawPluginApi,
} from "../api.js";
import {
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
resolveDiffsPluginViewerBaseUrl,
} from "./config.js";
import { createDiffsHttpHandler } from "./http.js";
import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
export function registerDiffsPlugin(api: OpenClawPluginApi): void {
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
const resolveCurrentPluginConfig = () =>
resolveLivePluginConfigObject(
api.runtime.config?.current
? () => api.runtime.config.current() as OpenClawConfig
: undefined,
"diffs",
api.pluginConfig as Record<string, unknown>,
) ?? {};
const resolveCurrentAccessConfig = () => {
const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
const pluginConfig = resolveCurrentPluginConfig();
return {
allowRemoteViewer: resolveDiffsPluginSecurity(pluginConfig).allowRemoteViewer,
trustedProxies: currentConfig.gateway?.trustedProxies,
allowRealIpFallback: currentConfig.gateway?.allowRealIpFallback === true,
};
};
const initialAccessConfig = resolveCurrentAccessConfig();
api.registerTool(
(ctx) => {
const pluginConfig = resolveCurrentPluginConfig();
return createDiffsTool({
api,
store,
defaults: resolveDiffsPluginDefaults(pluginConfig),
viewerBaseUrl: resolveDiffsPluginViewerBaseUrl(pluginConfig),
languagePackAvailable: resolveDiffsLanguagePackAvailability(api),
context: ctx,
});
},
{
name: "diffs",
},
);
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
handler: createDiffsHttpHandler({
store,
logger: api.logger,
allowRemoteViewer: initialAccessConfig.allowRemoteViewer,
trustedProxies: initialAccessConfig.trustedProxies,
allowRealIpFallback: initialAccessConfig.allowRealIpFallback,
resolveAccessConfig: resolveCurrentAccessConfig,
}),
});
api.on("before_prompt_build", async () => ({
prependSystemContext: DIFFS_AGENT_GUIDANCE,
}));
}
export function resolveDiffsLanguagePackAvailability(api: OpenClawPluginApi): boolean {
const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
const plugins = currentConfig.plugins;
if (plugins?.enabled === false) {
return false;
}
if (plugins?.deny?.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
return false;
}
if (plugins?.allow && !plugins.allow.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
return false;
}
if (plugins?.entries?.[DIFFS_LANGUAGE_PACK_PLUGIN_ID]?.enabled === false) {
return false;
}
return hasSiblingLanguagePackRuntime(api.rootDir);
}
function hasSiblingLanguagePackRuntime(rootDir: string | undefined): boolean {
if (!rootDir) {
return false;
}
const languagePackRoot = path.join(path.dirname(rootDir), DIFFS_LANGUAGE_PACK_PLUGIN_ID);
const runtimePaths = [
path.join(languagePackRoot, "assets", "viewer-runtime.js"),
path.join(languagePackRoot, "dist", "assets", "viewer-runtime.js"),
];
return (
fs.existsSync(path.join(languagePackRoot, "openclaw.plugin.json")) &&
runtimePaths.some((runtimePath) => fs.existsSync(runtimePath))
);
}

View File

@@ -0,0 +1,8 @@
// Diffs plugin module implements prompt guidance behavior.
export const DIFFS_AGENT_GUIDANCE = [
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"It accepts either `before` + `after` text or a unified `patch`.",
"`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
"If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
"Include `path` when you know the filename, and omit presentation overrides unless needed.",
].join("\n");

View File

@@ -0,0 +1,133 @@
// Diffs tests cover render target plugin behavior.
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const { preloadFileDiffMock, preloadMultiFileDiffMock } = vi.hoisted(() => ({
preloadFileDiffMock: vi.fn(async ({ fileDiff }: { fileDiff: unknown }) => ({
prerenderedHTML: "<div>mock diff</div>",
fileDiff,
})),
preloadMultiFileDiffMock: vi.fn(
async ({ oldFile, newFile }: { oldFile: unknown; newFile: unknown }) => ({
prerenderedHTML: "<div>mock diff</div>",
oldFile,
newFile,
}),
),
}));
vi.mock("@pierre/diffs/ssr", () => ({
preloadFileDiff: preloadFileDiffMock,
preloadMultiFileDiff: preloadMultiFileDiffMock,
}));
afterAll(() => {
vi.doUnmock("@pierre/diffs/ssr");
vi.resetModules();
});
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
function createRenderOptions() {
return {
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
};
}
describe("renderDiffDocument render targets", () => {
beforeEach(() => {
preloadFileDiffMock.mockClear();
preloadMultiFileDiffMock.mockClear();
});
it("renders only the viewer variant for before/after viewer mode", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "one\n",
after: "two\n",
},
createRenderOptions(),
"viewer",
);
expect(rendered.html).toContain("mock diff");
expect(rendered.imageHtml).toBeUndefined();
expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(1);
});
it("renders both variants for before/after both mode", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "one\n",
after: "two\n",
},
createRenderOptions(),
"both",
);
expect(rendered.html).toContain("mock diff");
expect(rendered.imageHtml).toContain("mock diff");
expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(2);
});
it("renders only the image variant for patch image mode", async () => {
const rendered = await renderDiffDocument(
{
kind: "patch",
patch: [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-a",
"+b",
].join("\n"),
},
createRenderOptions(),
"image",
);
expect(rendered.html).toBeUndefined();
expect(rendered.imageHtml).toContain("mock diff");
expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
});
it("normalizes stale patch payload languages before serializing viewer output", async () => {
preloadFileDiffMock.mockResolvedValueOnce({
prerenderedHTML: "<div>mock diff</div>",
fileDiff: {
name: "a.ts",
lang: "not-a-real-language",
},
});
const rendered = await renderDiffDocument(
{
kind: "patch",
patch: [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-a",
"+b",
].join("\n"),
},
createRenderOptions(),
"viewer",
);
const payloads = [
...(rendered.html ?? "").matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g),
].map((match) => parseViewerPayloadJson(match[1] ?? ""));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.langs).toEqual(["text"]);
expect(payloads[0]?.fileDiff?.lang).toBe("text");
});
});

View File

@@ -0,0 +1,245 @@
// Diffs tests cover render plugin behavior.
import { disposeHighlighter } from "@pierre/diffs";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
describe("renderDiffDocument", () => {
afterEach(async () => {
await disposeHighlighter();
});
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.viewerRuntime).toBe("base");
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("../../assets/viewer.js");
expect(rendered.imageHtml).toContain("../../assets/viewer.js");
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("normalizes non-finite presentation numbers before rendering CSS", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "old\n",
after: "new\n",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
fontSize: Number.NaN,
lineSpacing: Number.POSITIVE_INFINITY,
},
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).not.toContain("NaNpx");
expect(rendered.imageHtml).not.toContain("NaNpx");
});
it("resolves viewer assets under an optional base path", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
const html = rendered.html ?? "";
const loaderSrc = html.match(/<script type="module" src="([^"]+)"><\/script>/)?.[1];
expect(loaderSrc).toBe("../../assets/viewer.js");
expect(
new URL(loaderSrc ?? "", "https://example.com/openclaw/plugins/diffs/view/id/token").pathname,
).toBe("/openclaw/plugins/diffs/assets/viewer.js");
});
it("downgrades invalid language hints to plain text", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
lang: "not-a-real-language",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
const html = rendered.html ?? "";
expect(rendered.title).toBe("Text diff");
expect(html).toContain("diff.txt");
expect(html).not.toContain("not-a-real-language");
const payloads = [...html.matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g)].map(
(match) => parseViewerPayloadJson(match[1] ?? ""),
);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.langs).toEqual(["text"]);
expect(payloads[0]?.oldFile?.lang).toBeUndefined();
expect(payloads[0]?.newFile?.lang).toBeUndefined();
});
it("keeps uncommon language diffs readable without the language pack", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "REPORT z_demo.\n",
after: "REPORT z_demo2.\n",
lang: "abap",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
"viewer",
);
const html = rendered.html ?? "";
const payload = parseViewerPayloadJson(
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
);
expect(rendered.viewerRuntime).toBe("base");
expect(html).toContain("../../assets/viewer.js");
expect(html).not.toContain("diffs-language-pack");
expect(payload.langs).toEqual(["text"]);
});
it("uses the language-pack viewer runtime for uncommon languages when available", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "REPORT z_demo.\n",
after: "REPORT z_demo2.\n",
lang: "abap",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
languagePackAvailable: true,
},
"viewer",
);
const html = rendered.html ?? "";
const payload = parseViewerPayloadJson(
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
);
expect(rendered.viewerRuntime).toBe("language-pack");
expect(html).toContain("../../../diffs-language-pack/assets/viewer.js");
expect(payload.langs).toEqual(["abap"]);
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
});

View File

@@ -0,0 +1,617 @@
// Diffs plugin module implements render behavior.
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import { normalizeDiffFontSize, normalizeDiffLineSpacing } from "./config.js";
import {
collectDiffPayloadLanguageHints,
isBaseDiffViewerLanguage,
normalizeDiffViewerPayloadLanguages,
normalizeSupportedLanguageHint,
} from "./language-hints.js";
import type {
DiffInput,
DiffRenderOptions,
DiffRenderTarget,
DiffViewerOptions,
DiffViewerPayload,
RenderedDiffDocument,
} from "./types.js";
const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000;
const VIEWER_LOADER_DOCUMENT_PATH = "../../assets/viewer.js";
const LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH = "../../../diffs-language-pack/assets/viewer.js";
function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeJsonScript(value: unknown): string {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
function buildDiffTitle(input: DiffInput): string {
if (input.title?.trim()) {
return input.title.trim();
}
if (input.kind === "before_after") {
return input.path?.trim() || "Text diff";
}
return "Patch diff";
}
function resolveBeforeAfterFileName(params: {
input: Extract<DiffInput, { kind: "before_after" }>;
lang?: SupportedLanguages;
}): string {
const { input, lang } = params;
if (input.path?.trim()) {
return input.path.trim();
}
if (lang && lang !== "text") {
return `diff.${lang.replace(/^\.+/, "")}`;
}
return DEFAULT_FILE_NAME;
}
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
const fontFamily = escapeCssString(options.presentation.fontFamily);
const fontSize = normalizeDiffFontSize(options.presentation.fontSize);
const lineSpacing = normalizeDiffLineSpacing(options.presentation.lineSpacing);
const lineHeight = Math.max(20, Math.round(fontSize * lineSpacing));
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.presentation.layout,
diffIndicators: options.presentation.diffIndicators,
disableLineNumbers: !options.presentation.showLineNumbers,
expandUnchanged: options.expandUnchanged,
themeType: options.presentation.theme,
backgroundEnabled: options.presentation.background,
overflow: options.presentation.wordWrap ? "wrap" : "scroll",
unsafeCSS: `
:host {
--diffs-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: ${fontSize}px;
--diffs-line-height: ${lineHeight}px;
}
[data-diffs-header] {
min-height: 64px;
padding-inline: 18px 14px;
}
[data-header-content] {
gap: 10px;
}
[data-metadata] {
gap: 10px;
}
.oc-diff-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
margin-inline-start: 6px;
flex: 0 0 auto;
}
.oc-diff-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
line-height: 0;
overflow: visible;
transition: opacity 120ms ease;
flex: 0 0 auto;
}
.oc-diff-toolbar-button:hover {
opacity: 1;
}
.oc-diff-toolbar-button[data-active="true"] {
opacity: 0.92;
}
.oc-diff-toolbar-button svg {
display: block;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
overflow: visible;
flex: 0 0 auto;
color: inherit;
fill: currentColor;
pointer-events: none;
}
`,
};
}
function buildImageRenderOptions(options: DiffRenderOptions): DiffRenderOptions {
return {
...options,
presentation: {
...options.presentation,
fontSize: Math.max(16, normalizeDiffFontSize(options.presentation.fontSize)),
},
};
}
function shouldRenderViewer(target: DiffRenderTarget): boolean {
return target === "viewer" || target === "both";
}
function shouldRenderImage(target: DiffRenderTarget): boolean {
return target === "image" || target === "both";
}
function buildRenderVariants(params: { options: DiffRenderOptions; target: DiffRenderTarget }): {
viewerOptions?: DiffViewerOptions;
imageOptions?: DiffViewerOptions;
} {
return {
...(shouldRenderViewer(params.target)
? { viewerOptions: buildDiffOptions(params.options) }
: {}),
...(shouldRenderImage(params.target)
? { imageOptions: buildDiffOptions(buildImageRenderOptions(params.options)) }
: {}),
};
}
function renderDiffCard(payload: DiffViewerPayload): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
</diffs-container>
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
</section>`;
}
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["presentation"]["theme"];
imageMaxWidth: number;
runtimeMode: "viewer" | "image";
viewerRuntime: "base" | "language-pack";
}): string {
const viewerLoaderPath =
params.viewerRuntime === "language-pack"
? LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH
: VIEWER_LOADER_DOCUMENT_PATH;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>${escapeHtml(params.title)}</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
html {
background: #05070b;
}
body {
margin: 0;
min-height: 100vh;
padding: 22px;
font-family:
"Fira Code",
"SF Mono",
Monaco,
Consolas,
monospace;
background: #05070b;
color: #f8fafc;
}
body[data-theme="light"] {
background: #f3f5f8;
color: #0f172a;
}
.oc-frame {
max-width: 1560px;
margin: 0 auto;
}
.oc-frame[data-render-mode="image"] {
max-width: ${Math.max(640, Math.round(params.imageMaxWidth))}px;
}
[data-openclaw-diff-root] {
display: grid;
gap: 18px;
}
.oc-diff-card {
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.14);
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
}
body[data-theme="light"] .oc-diff-card {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}
.oc-diff-host {
display: block;
}
.oc-frame[data-render-mode="image"] .oc-diff-card {
min-height: 240px;
}
@media (max-width: 720px) {
body {
padding: 12px;
}
[data-openclaw-diff-root] {
gap: 12px;
}
}
</style>
</head>
<body data-theme="${params.theme}">
<main class="oc-frame" data-render-mode="${params.runtimeMode}">
<div data-openclaw-diff-root>
${params.bodyHtml}
</div>
</main>
<script type="module" src="${viewerLoaderPath}"></script>
</body>
</html>`;
}
type RenderedSection = {
viewer?: string;
image?: string;
usesLanguagePack?: boolean;
};
function payloadUsesLanguagePack(payload: DiffViewerPayload | undefined): boolean {
return payload?.langs.some((lang) => !isBaseDiffViewerLanguage(lang)) ?? false;
}
function buildRenderedSection(params: {
viewerPayload?: DiffViewerPayload;
imagePayload?: DiffViewerPayload;
}): RenderedSection {
return {
...(params.viewerPayload ? { viewer: renderDiffCard(params.viewerPayload) } : {}),
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload) } : {}),
usesLanguagePack:
payloadUsesLanguagePack(params.viewerPayload) || payloadUsesLanguagePack(params.imagePayload),
};
}
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
viewerBodyHtml?: string;
imageBodyHtml?: string;
} {
const viewerSections = sections.flatMap((section) => (section.viewer ? [section.viewer] : []));
const imageSections = sections.flatMap((section) => (section.image ? [section.image] : []));
return {
...(viewerSections.length > 0 ? { viewerBodyHtml: viewerSections.join("\n") } : {}),
...(imageSections.length > 0 ? { imageBodyHtml: imageSections.join("\n") } : {}),
};
}
async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
target: DiffRenderTarget,
): Promise<{
viewerBodyHtml?: string;
imageBodyHtml?: string;
fileCount: number;
usesLanguagePack: boolean;
}> {
const languagePackAvailable = options.languagePackAvailable === true;
const lang = await normalizeSupportedLanguageHint(input.lang, { languagePackAvailable });
const fileName = resolveBeforeAfterFileName({ input, lang });
const oldFile: FileContents = {
name: fileName,
contents: input.before,
...(lang ? { lang } : {}),
};
const newFile: FileContents = {
name: fileName,
contents: input.after,
...(lang ? { lang } : {}),
};
const { viewerOptions, imageOptions } = buildRenderVariants({ options, target });
const [viewerResult, imageResult] = await Promise.all([
viewerOptions
? preloadMultiFileDiffWithFallback({
oldFile,
newFile,
options: viewerOptions,
})
: Promise.resolve(undefined),
imageOptions
? preloadMultiFileDiffWithFallback({
oldFile,
newFile,
options: imageOptions,
})
: Promise.resolve(undefined),
]);
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: viewerResult.prerenderedHTML,
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: imageResult.prerenderedHTML,
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
}),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
]);
const section = buildRenderedSection({
...(viewerPayload ? { viewerPayload } : {}),
...(imagePayload ? { imagePayload } : {}),
});
return {
...buildRenderedBodies([section]),
fileCount: 1,
usesLanguagePack: section.usesLanguagePack === true,
};
}
async function renderPatchDiff(
input: Extract<DiffInput, { kind: "patch" }>,
options: DiffRenderOptions,
target: DiffRenderTarget,
): Promise<{
viewerBodyHtml?: string;
imageBodyHtml?: string;
fileCount: number;
usesLanguagePack: boolean;
}> {
const languagePackAvailable = options.languagePackAvailable === true;
const files = await Promise.all(
parsePatchFiles(input.patch)
.flatMap((entry) => entry.files ?? [])
.map((fileDiff) => normalizePatchFileLanguage(fileDiff, { languagePackAvailable })),
);
if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs.");
}
if (files.length > MAX_PATCH_FILE_COUNT) {
throw new Error(`Patch input contains too many files (max ${MAX_PATCH_FILE_COUNT}).`);
}
const totalLines = files.reduce((sum, fileDiff) => {
const splitLines = Number.isFinite(fileDiff.splitLineCount) ? fileDiff.splitLineCount : 0;
const unifiedLines = Number.isFinite(fileDiff.unifiedLineCount) ? fileDiff.unifiedLineCount : 0;
return sum + Math.max(splitLines, unifiedLines, 0);
}, 0);
if (totalLines > MAX_PATCH_TOTAL_LINES) {
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
}
const { viewerOptions, imageOptions } = buildRenderVariants({ options, target });
const sections = await Promise.all(
files.map(async (fileDiff) => {
const [viewerResult, imageResult] = await Promise.all([
viewerOptions
? preloadFileDiffWithFallback({
fileDiff,
options: viewerOptions,
})
: Promise.resolve(undefined),
imageOptions
? preloadFileDiffWithFallback({
fileDiff,
options: imageOptions,
})
: Promise.resolve(undefined),
]);
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: viewerResult.fileDiff }),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: imageResult.fileDiff }),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
]);
return buildRenderedSection({
...(viewerPayload ? { viewerPayload } : {}),
...(imagePayload ? { imagePayload } : {}),
});
}),
);
return {
...buildRenderedBodies(sections),
fileCount: files.length,
usesLanguagePack: sections.some((section) => section.usesLanguagePack === true),
};
}
async function normalizePatchFileLanguage(
fileDiff: FileDiffMetadata,
options: { languagePackAvailable: boolean },
): Promise<FileDiffMetadata> {
const lang = await normalizeSupportedLanguageHint(fileDiff.lang, options);
if (lang === fileDiff.lang) {
return fileDiff;
}
return {
...fileDiff,
...(lang ? { lang } : { lang: "text" }),
};
}
export async function renderDiffDocument(
input: DiffInput,
options: DiffRenderOptions,
target: DiffRenderTarget = "both",
): Promise<RenderedDiffDocument> {
const title = buildDiffTitle(input);
const rendered =
input.kind === "before_after"
? await renderBeforeAfterDiff(input, options, target)
: await renderPatchDiff(input, options, target);
const viewerRuntime = rendered.usesLanguagePack ? "language-pack" : "base";
return {
...(rendered.viewerBodyHtml
? {
html: buildHtmlDocument({
title,
bodyHtml: rendered.viewerBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "viewer",
viewerRuntime,
}),
}
: {}),
...(rendered.imageBodyHtml
? {
imageHtml: buildHtmlDocument({
title,
bodyHtml: rendered.imageBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "image",
viewerRuntime,
}),
}
: {}),
title,
fileCount: rendered.fileCount,
inputKind: input.kind,
viewerRuntime,
};
}
type PreloadedFileDiffResult = Awaited<ReturnType<typeof preloadFileDiff>>;
type PreloadedMultiFileDiffResult = Awaited<ReturnType<typeof preloadMultiFileDiff>>;
function shouldFallbackToClientHydration(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message.includes('needs an import attribute of "type: json"')
);
}
async function preloadFileDiffWithFallback(params: {
fileDiff: FileDiffMetadata;
options: DiffViewerOptions;
}): Promise<PreloadedFileDiffResult> {
try {
return await preloadFileDiff(params);
} catch (error) {
if (!shouldFallbackToClientHydration(error)) {
throw error;
}
return {
fileDiff: params.fileDiff,
prerenderedHTML: "",
};
}
}
async function preloadMultiFileDiffWithFallback(params: {
oldFile: FileContents;
newFile: FileContents;
options: DiffViewerOptions;
}): Promise<PreloadedMultiFileDiffResult> {
try {
return await preloadMultiFileDiff(params);
} catch (error) {
if (!shouldFallbackToClientHydration(error)) {
throw error;
}
return {
oldFile: params.oldFile,
newFile: params.newFile,
prerenderedHTML: "",
};
}
}

View File

@@ -0,0 +1,87 @@
// Diffs plugin module implements shiki curated languages behavior.
const javascript = () => import("@shikijs/langs/javascript");
const typescript = () => import("@shikijs/langs/typescript");
const tsx = () => import("@shikijs/langs/tsx");
const jsx = () => import("@shikijs/langs/jsx");
const json = () => import("@shikijs/langs/json");
const markdown = () => import("@shikijs/langs/markdown");
const yaml = () => import("@shikijs/langs/yaml");
const css = () => import("@shikijs/langs/css");
const html = () => import("@shikijs/langs/html");
const sh = () => import("@shikijs/langs/sh");
const python = () => import("@shikijs/langs/python");
const go = () => import("@shikijs/langs/go");
const rust = () => import("@shikijs/langs/rust");
const java = () => import("@shikijs/langs/java");
const c = () => import("@shikijs/langs/c");
const cpp = () => import("@shikijs/langs/cpp");
const csharp = () => import("@shikijs/langs/csharp");
const php = () => import("@shikijs/langs/php");
const sql = () => import("@shikijs/langs/sql");
const docker = () => import("@shikijs/langs/docker");
const ruby = () => import("@shikijs/langs/ruby");
const swift = () => import("@shikijs/langs/swift");
const kotlin = () => import("@shikijs/langs/kotlin");
const r = () => import("@shikijs/langs/r");
const dart = () => import("@shikijs/langs/dart");
const lua = () => import("@shikijs/langs/lua");
const powershell = () => import("@shikijs/langs/powershell");
const xml = () => import("@shikijs/langs/xml");
const toml = () => import("@shikijs/langs/toml");
type CuratedLanguageInfo = {
readonly id: string;
readonly name: string;
readonly aliases?: readonly string[];
readonly import: () => Promise<unknown>;
};
export const bundledLanguagesInfo = [
{ id: "javascript", name: "JavaScript", aliases: ["js", "mjs", "cjs"], import: javascript },
{ id: "typescript", name: "TypeScript", aliases: ["ts", "mts", "cts"], import: typescript },
{ id: "tsx", name: "TSX", import: tsx },
{ id: "jsx", name: "JSX", import: jsx },
{ id: "json", name: "JSON", aliases: ["jsonc", "json5", "jsonl"], import: json },
{ id: "markdown", name: "Markdown", aliases: ["md"], import: markdown },
{ id: "yaml", name: "YAML", aliases: ["yml"], import: yaml },
{ id: "css", name: "CSS", import: css },
{ id: "html", name: "HTML", import: html },
{ id: "sh", name: "Shell", aliases: ["bash", "shell", "shellscript", "zsh"], import: sh },
{ id: "python", name: "Python", aliases: ["py"], import: python },
{ id: "go", name: "Go", import: go },
{ id: "rust", name: "Rust", aliases: ["rs"], import: rust },
{ id: "java", name: "Java", import: java },
{ id: "c", name: "C", import: c },
{ id: "cpp", name: "C++", aliases: ["c++"], import: cpp },
{ id: "csharp", name: "C#", aliases: ["c#", "cs"], import: csharp },
{ id: "php", name: "PHP", import: php },
{ id: "sql", name: "SQL", import: sql },
{ id: "docker", name: "Docker", aliases: ["dockerfile"], import: docker },
{ id: "ruby", name: "Ruby", aliases: ["rb"], import: ruby },
{ id: "swift", name: "Swift", import: swift },
{ id: "kotlin", name: "Kotlin", aliases: ["kt", "kts"], import: kotlin },
{ id: "r", name: "R", import: r },
{ id: "dart", name: "Dart", import: dart },
{ id: "lua", name: "Lua", import: lua },
{ id: "powershell", name: "PowerShell", aliases: ["ps", "ps1"], import: powershell },
{ id: "xml", name: "XML", import: xml },
{ id: "toml", name: "TOML", import: toml },
] as const satisfies readonly CuratedLanguageInfo[];
export const bundledLanguagesBase = Object.fromEntries(
bundledLanguagesInfo.map((language) => [language.id, language.import]),
);
export function getBundledLanguageAliases(
language: (typeof bundledLanguagesInfo)[number],
): readonly string[] {
return "aliases" in language ? language.aliases : [];
}
export const bundledLanguagesAlias = Object.fromEntries(
bundledLanguagesInfo.flatMap((language) =>
getBundledLanguageAliases(language).map((alias) => [alias, language.import]),
),
);
export const bundledLanguages = {
...bundledLanguagesBase,
...bundledLanguagesAlias,
};

View File

@@ -0,0 +1,491 @@
// Diffs tests cover store plugin behavior.
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness, ensureCuratedViewerRuntimeForTests } from "./test-helpers.js";
beforeAll(async () => {
await ensureCuratedViewerRuntimeForTests();
});
describe("DiffArtifactStore", () => {
let rootDir: string;
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
beforeEach(async () => {
({
rootDir,
store,
cleanup: cleanupRootDir,
} = await createDiffStoreHarness("openclaw-diffs-store-"));
});
afterEach(async () => {
vi.useRealTimers();
await cleanupRootDir();
});
it("creates and retrieves an artifact", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
context: {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
});
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded?.id).toBe(artifact.id);
expect(loaded?.context).toEqual({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
});
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
});
it("caps artifact expiry instead of throwing near the Date boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "patch",
fileCount: 1,
ttlMs: 60_000,
});
expect(artifact.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
});
it("expires artifacts after the ttl", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "patch",
fileCount: 2,
ttlMs: 1_000,
});
vi.setSystemTime(new Date(now.getTime() + 2_000));
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded).toBeNull();
});
it("updates the stored file path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const filePath = store.allocateFilePath(artifact.id);
const updated = await store.updateFilePath(artifact.id, filePath);
expect(updated.filePath).toBe(filePath);
expect(updated.imagePath).toBe(filePath);
});
it("rejects file paths that escape the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
await expect(store.updateFilePath(artifact.id, "../outside.png")).rejects.toThrow(
"escapes store root",
);
});
it("rejects tampered html metadata paths outside the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const metaPath = path.join(rootDir, artifact.id, "meta.json");
const rawMeta = await fs.readFile(metaPath, "utf8");
const meta = JSON.parse(rawMeta) as { htmlPath: string };
meta.htmlPath = "../outside.html";
await fs.writeFile(metaPath, JSON.stringify(meta), "utf8");
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
});
it("creates standalone file artifacts with managed metadata", async () => {
const standalone = await store.createStandaloneFileArtifact({
context: {
agentId: "main",
sessionId: "session-123",
},
});
expect(standalone.filePath).toMatch(/preview\.png$/);
expect(standalone.filePath).toContain(rootDir);
expect(Date.parse(standalone.expiresAt)).toBeGreaterThan(Date.now());
expect(standalone.context).toEqual({
agentId: "main",
sessionId: "session-123",
});
});
it("caps standalone file expiry instead of throwing near the Date boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
const standalone = await store.createStandaloneFileArtifact({ ttlMs: 60_000 });
expect(standalone.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
});
it("expires standalone file artifacts using ttl metadata", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const standalone = await store.createStandaloneFileArtifact({
format: "png",
ttlMs: 1_000,
});
await fs.writeFile(standalone.filePath, Buffer.from("png"));
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
const error = await fs.stat(path.dirname(standalone.filePath)).then(
() => undefined,
(statError: unknown) => statError,
);
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
});
it("supports image path aliases for backward compatibility", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id, "pdf");
expect(imagePath).toMatch(/preview\.pdf$/);
const standalone = await store.createStandaloneFileArtifact();
expect(standalone.filePath).toMatch(/preview\.png$/);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.filePath).toBe(imagePath);
expect(updated.imagePath).toBe(imagePath);
});
it("allocates PDF file paths when format is pdf", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const artifactPdf = store.allocateFilePath(artifact.id, "pdf");
const standalonePdf = await store.createStandaloneFileArtifact({ format: "pdf" });
expect(artifactPdf).toMatch(/preview\.pdf$/);
expect(standalonePdf.filePath).toMatch(/preview\.pdf$/);
});
it("throttles cleanup sweeps across repeated artifact creation", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
store = new DiffArtifactStore({
rootDir,
cleanupIntervalMs: 60_000,
});
const cleanupSpy = vi.spyOn(store, "cleanupExpired").mockResolvedValue();
await store.createArtifact({
html: "<html>one</html>",
title: "One",
inputKind: "before_after",
fileCount: 1,
});
await store.createArtifact({
html: "<html>two</html>",
title: "Two",
inputKind: "before_after",
fileCount: 1,
});
expect(cleanupSpy).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date(now.getTime() + 61_000));
await store.createArtifact({
html: "<html>three</html>",
title: "Three",
inputKind: "before_after",
fileCount: 1,
});
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});
});
describe("createDiffsHttpHandler", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
async function handleLocalGet(url: string) {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url,
}),
res,
);
return { handled, res };
}
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("serves a stored diff document", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(artifact.viewerPath);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(
artifact.viewerPath.replace(artifact.token, "bad-token"),
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("./viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
it.each([
{
name: "allows direct loopback viewer access by default",
request: localReq,
allowRemoteViewer: false,
expectedStatusCode: 200,
},
{
name: "allows ipv4-mapped ipv6 loopback viewer access by default",
request: ipv4MappedLoopbackReq,
allowRemoteViewer: false,
expectedStatusCode: 200,
},
{
name: "blocks non-loopback viewer access by default",
request: remoteReq,
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks loopback requests that carry proxy forwarding headers by default",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks trusted-proxy loopback requests without client-origin headers by default",
request: localReq,
trustedProxies: ["127.0.0.1"],
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks proxied loopback requests when trusted proxies are configured",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
trustedProxies: ["127.0.0.1"],
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,
allowRemoteViewer: true,
expectedStatusCode: 200,
},
{
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
trustedProxies: ["127.0.0.1"],
allowRemoteViewer: true,
expectedStatusCode: 200,
},
])(
"$name",
async ({ request, headers, trustedProxies, allowRemoteViewer, expectedStatusCode }) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer, trustedProxies });
const res = createMockServerResponse();
const handled = await handler(
request({
method: "GET",
url: artifact.viewerPath,
headers,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatusCode);
if (expectedStatusCode === 200) {
expect(res.body).toBe("<html>viewer</html>");
}
},
);
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
});
async function createViewerArtifact(store: DiffArtifactStore) {
return await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
}
function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}
function ipv4MappedLoopbackReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "::ffff:127.0.0.1" },
} as unknown as IncomingMessage;
}

View File

@@ -0,0 +1,399 @@
// Diffs plugin module implements store behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PluginLogger } from "../api.js";
import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const VIEWER_PREFIX = "/plugins/diffs/view";
type CreateArtifactParams = {
html: string;
title: string;
inputKind: DiffArtifactMeta["inputKind"];
fileCount: number;
ttlMs?: number;
context?: DiffArtifactContext;
};
type CreateStandaloneFileArtifactParams = {
format?: DiffOutputFormat;
ttlMs?: number;
context?: DiffArtifactContext;
};
type StandaloneFileMeta = {
kind: "standalone_file";
id: string;
createdAt: string;
expiresAt: string;
filePath: string;
context?: DiffArtifactContext;
};
type ArtifactMetaFileName = "meta.json" | "file-meta.json";
type ArtifactRoot = Awaited<ReturnType<typeof fsRoot>>;
export class DiffArtifactStore {
private readonly rootDir: string;
private readonly logger?: PluginLogger;
private readonly cleanupIntervalMs: number;
private cleanupInFlight: Promise<void> | null = null;
private nextCleanupAt = 0;
constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) {
this.rootDir = path.resolve(params.rootDir);
this.logger = params.logger;
this.cleanupIntervalMs =
params.cleanupIntervalMs === undefined
? DEFAULT_CLEANUP_INTERVAL_MS
: Math.max(0, Math.floor(params.cleanupIntervalMs));
}
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
await this.ensureRoot();
const id = crypto.randomBytes(10).toString("hex");
const token = crypto.randomBytes(24).toString("hex");
const artifactDir = this.artifactDir(id);
const htmlPath = path.join(artifactDir, "viewer.html");
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const createdAtIso = createdAt.toISOString();
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
const meta: DiffArtifactMeta = {
id,
token,
title: params.title,
inputKind: params.inputKind,
fileCount: params.fileCount,
createdAt: createdAtIso,
expiresAt,
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
htmlPath,
...(params.context ? { context: params.context } : {}),
};
const root = await this.artifactRoot();
await root.mkdir(id);
await root.write(path.posix.join(id, "viewer.html"), params.html);
await this.writeMeta(meta);
this.scheduleCleanup();
return meta;
}
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
const meta = await this.readMeta(id);
if (!meta) {
return null;
}
if (meta.token !== token) {
return null;
}
if (isExpired(meta)) {
await this.deleteArtifact(id);
return null;
}
return meta;
}
async readHtml(id: string): Promise<string> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
return await (await this.artifactRoot()).readText(this.relativeStoredPath(htmlPath));
}
async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
const next: DiffArtifactMeta = {
...meta,
filePath: normalizedFilePath,
imagePath: normalizedFilePath,
};
await this.writeMeta(next);
return next;
}
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
return this.updateFilePath(id, imagePath);
}
allocateFilePath(id: string, format: DiffOutputFormat = "png"): string {
return path.join(this.artifactDir(id), `preview.${format}`);
}
async createStandaloneFileArtifact(
params: CreateStandaloneFileArtifactParams = {},
): Promise<{ id: string; filePath: string; expiresAt: string; context?: DiffArtifactContext }> {
await this.ensureRoot();
const id = crypto.randomBytes(10).toString("hex");
const artifactDir = this.artifactDir(id);
const format = params.format ?? "png";
const filePath = path.join(artifactDir, `preview.${format}`);
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const createdAtIso = createdAt.toISOString();
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
const meta: StandaloneFileMeta = {
kind: "standalone_file",
id,
createdAt: createdAtIso,
expiresAt,
filePath: this.normalizeStoredPath(filePath, "filePath"),
...(params.context ? { context: params.context } : {}),
};
await (await this.artifactRoot()).mkdir(id);
await this.writeStandaloneMeta(meta);
this.scheduleCleanup();
return {
id,
filePath: meta.filePath,
expiresAt: meta.expiresAt,
...(meta.context ? { context: meta.context } : {}),
};
}
allocateImagePath(id: string, format: DiffOutputFormat = "png"): string {
return this.allocateFilePath(id, format);
}
scheduleCleanup(): void {
this.maybeCleanupExpired();
}
async cleanupExpired(): Promise<void> {
const root = await this.artifactRoot();
const entries = await root.list("", { withFileTypes: true }).catch(() => []);
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isDirectory)
.map(async (entry) => {
const id = entry.name;
const meta = await this.readMeta(id);
if (meta) {
if (isExpired(meta)) {
await this.deleteArtifact(id);
}
return;
}
const standaloneMeta = await this.readStandaloneMeta(id);
if (standaloneMeta) {
if (isExpired(standaloneMeta)) {
await this.deleteArtifact(id);
}
return;
}
if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
await this.deleteArtifact(id);
}
}),
);
}
private async ensureRoot(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
}
private async artifactRoot(): Promise<ArtifactRoot> {
await this.ensureRoot();
return await fsRoot(this.rootDir);
}
private maybeCleanupExpired(): void {
const now = Date.now();
if (this.cleanupInFlight || now < this.nextCleanupAt) {
return;
}
this.nextCleanupAt = now + this.cleanupIntervalMs;
const cleanupPromise = this.cleanupExpired()
.catch((error: unknown) => {
this.nextCleanupAt = 0;
this.logger?.warn(`Failed to clean expired diff artifacts: ${String(error)}`);
})
.finally(() => {
if (this.cleanupInFlight === cleanupPromise) {
this.cleanupInFlight = null;
}
});
this.cleanupInFlight = cleanupPromise;
}
private artifactDir(id: string): string {
return this.resolveWithinRoot(id);
}
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
await this.writeJsonMeta(meta.id, "meta.json", meta);
}
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact");
if (!parsed) {
return null;
}
return parsed as DiffArtifactMeta;
}
private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise<void> {
await this.writeJsonMeta(meta.id, "file-meta.json", meta);
}
private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> {
const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff");
if (!parsed) {
return null;
}
try {
const value = parsed as Partial<StandaloneFileMeta>;
if (
value.kind !== "standalone_file" ||
typeof value.id !== "string" ||
typeof value.createdAt !== "string" ||
typeof value.expiresAt !== "string" ||
typeof value.filePath !== "string"
) {
return null;
}
return {
kind: value.kind,
id: value.id,
createdAt: value.createdAt,
expiresAt: value.expiresAt,
filePath: this.normalizeStoredPath(value.filePath, "filePath"),
...(value.context ? { context: normalizeArtifactContext(value.context) } : {}),
};
} catch (error) {
this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
return null;
}
}
private async writeJsonMeta(
id: string,
fileName: ArtifactMetaFileName,
data: unknown,
): Promise<void> {
await (await this.artifactRoot()).writeJson(path.posix.join(id, fileName), data, { space: 2 });
}
private async readJsonMeta(
id: string,
fileName: ArtifactMetaFileName,
context: string,
): Promise<unknown> {
try {
const raw = await (await this.artifactRoot()).readText(path.posix.join(id, fileName));
return JSON.parse(raw) as unknown;
} catch (error) {
if (isFileNotFound(error)) {
return null;
}
this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`);
return null;
}
}
private async deleteArtifact(id: string): Promise<void> {
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
}
private resolveWithinRoot(...parts: string[]): string {
const candidate = path.resolve(this.rootDir, ...parts);
this.assertWithinRoot(candidate);
return candidate;
}
private normalizeStoredPath(rawPath: string, label: string): string {
const candidate = path.isAbsolute(rawPath)
? path.resolve(rawPath)
: path.resolve(this.rootDir, rawPath);
this.assertWithinRoot(candidate, label);
return candidate;
}
private relativeStoredPath(storedPath: string): string {
const relativePath = path.relative(this.rootDir, this.normalizeStoredPath(storedPath, "path"));
return relativePath.split(path.sep).join(path.posix.sep);
}
private assertWithinRoot(candidate: string, label = "path"): void {
const relative = path.relative(this.rootDir, candidate);
if (
relative === "" ||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
) {
return;
}
throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`);
}
}
function normalizeTtlMs(value?: number): number {
if (!Number.isFinite(value) || value === undefined) {
return DEFAULT_TTL_MS;
}
const rounded = Math.floor(value);
if (rounded <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(rounded, MAX_TTL_MS);
}
function resolveExpiresAtIso(createdAtMs: number, ttlMs: number): string {
return (
timestampMsToIsoString(createdAtMs + ttlMs) ??
timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ??
"1970-01-01T00:00:00.000Z"
);
}
function isExpired(meta: { expiresAt: string }): boolean {
const expiresAt = Date.parse(meta.expiresAt);
if (!Number.isFinite(expiresAt)) {
return true;
}
return Date.now() >= expiresAt;
}
function isFileNotFound(error: unknown): boolean {
const code = error instanceof Error && "code" in error ? error.code : undefined;
return code === "ENOENT" || code === "not-found";
}
function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
const context = {
agentId: normalizeOptionalString(raw.agentId),
sessionId: normalizeOptionalString(raw.sessionId),
messageChannel: normalizeOptionalString(raw.messageChannel),
agentAccountId: normalizeOptionalString(raw.agentAccountId),
};
return Object.values(context).some((entry) => entry !== undefined) ? context : undefined;
}

View File

@@ -0,0 +1,62 @@
// Diffs helper module supports test helpers behavior.
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { resolvePreferredOpenClawTmpDir } from "../api.js";
import { DiffArtifactStore } from "./store.js";
const execFileAsync = promisify(execFile);
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.stat(filePath);
return true;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return false;
}
throw error;
}
}
export async function ensureCuratedViewerRuntimeForTests(): Promise<void> {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const runtimePath = path.join(repoRoot, "extensions", "diffs", "assets", "viewer-runtime.js");
if (await pathExists(runtimePath)) {
return;
}
// The curated runtime is generated output. Source tests that serve viewer
// assets need a clean-checkout fixture before the normal build hook runs.
await execFileAsync(process.execPath, ["scripts/build-diffs-viewer-runtime.mjs", "curated"], {
cwd: repoRoot,
});
}
export async function createTempDiffRoot(prefix: string): Promise<{
rootDir: string;
cleanup: () => Promise<void>;
}> {
const rootDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
return {
rootDir,
cleanup: async () => {
await fs.rm(rootDir, { recursive: true, force: true });
},
};
}
export async function createDiffStoreHarness(prefix: string): Promise<{
rootDir: string;
store: DiffArtifactStore;
cleanup: () => Promise<void>;
}> {
const { rootDir, cleanup } = await createTempDiffRoot(prefix);
return {
rootDir,
store: new DiffArtifactStore({ rootDir }),
cleanup,
};
}

View File

@@ -0,0 +1,108 @@
// Diffs tests cover tool render output plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import type { DiffScreenshotter } from "./browser.js";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { createDiffStoreHarness } from "./test-helpers.js";
const { renderDiffDocumentMock } = vi.hoisted(() => ({
renderDiffDocumentMock: vi.fn(),
}));
vi.mock("./render.js", () => ({
renderDiffDocument: renderDiffDocumentMock,
}));
afterAll(() => {
vi.doUnmock("./render.js");
vi.resetModules();
});
describe("diffs tool rendered output guards", () => {
let createDiffsTool: typeof import("./tool.js").createDiffsTool;
let cleanupRootDir: () => Promise<void>;
let store: Awaited<ReturnType<typeof createDiffStoreHarness>>["store"];
beforeAll(async () => {
({ createDiffsTool } = await import("./tool.js"));
});
beforeEach(async () => {
renderDiffDocumentMock.mockReset();
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness(
"openclaw-diffs-tool-render-output-",
));
});
afterEach(async () => {
await cleanupRootDir();
});
it("accepts empty string image html for file output", async () => {
renderDiffDocumentMock.mockResolvedValue({
title: "Text diff",
fileCount: 1,
inputKind: "before_after",
imageHtml: "",
});
const screenshotter = createPngScreenshotter({
assertHtml: (html) => {
expect(html).toBe("");
},
});
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-empty-image-html", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.png$/);
});
});
function createApi(): OpenClawPluginApi {
return createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
runtime: {} as OpenClawPluginApi["runtime"],
});
}
function createPngScreenshotter(
params: {
assertHtml?: (html: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({ html, outputPath }: { html: string; outputPath: string }) => {
params.assertHtml?.(html);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
);
return {
screenshotHtml,
};
}

View File

@@ -0,0 +1,672 @@
// Diffs tests cover tool plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import type { DiffScreenshotter } from "./browser.js";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
import { createDiffsTool } from "./tool.js";
import type { DiffRenderOptions } from "./types.js";
describe("diffs tool", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-tool-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("returns a viewer URL in view mode", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const result = await tool.execute?.("tool-1", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
const text = readTextContent(result, 0);
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
expect(String(readDetails(result).viewerUrl)).toContain(
"http://127.0.0.1:18789/plugins/diffs/view/",
);
});
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
const tool = createDiffsTool({
api: createApi({
viewerBaseUrl: "https://example.com/openclaw/",
}),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
viewerBaseUrl: "https://example.com/openclaw",
});
const result = await tool.execute?.("tool-viewer-config", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
expect(readTextContent(result, 0)).toContain(
"https://example.com/openclaw/plugins/diffs/view/",
);
expect(String((result.details as Record<string, unknown>).viewerUrl)).toContain(
"https://example.com/openclaw/plugins/diffs/view/",
);
});
it("prefers per-call baseUrl over configured viewerBaseUrl", async () => {
const tool = createDiffsTool({
api: createApi({
viewerBaseUrl: "https://example.com/openclaw",
}),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
viewerBaseUrl: "https://example.com/openclaw",
});
const result = await tool.execute?.("tool-viewer-override", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
baseUrl: "https://preview.example.com/review",
});
expect(readTextContent(result, 0)).toContain(
"https://preview.example.com/review/plugins/diffs/view/",
);
expect(String((result.details as Record<string, unknown>).viewerUrl)).toContain(
"https://preview.example.com/review/plugins/diffs/view/",
);
});
it("does not expose reserved format in the tool schema", () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const properties = readParametersProperties(tool.parameters);
expect(properties).not.toHaveProperty("format");
});
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = createPngScreenshotter({
assertHtml: (html) => {
expect(html).toContain("../../assets/viewer.js");
},
assertImage: (image) => {
expect(image.format).toBe("png");
expect(image.qualityPreset).toBe("standard");
expect(image.scale).toBe(2);
expect(image.maxWidth).toBe(960);
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2", {
before: "one\n",
after: "two\n",
mode: "image",
});
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
const details = readDetails(result);
expect(requireString(details.filePath, "filePath")).toMatch(/preview\.png$/);
expect(requireString(details.imagePath, "imagePath")).toMatch(/preview\.png$/);
expect(details.format).toBe("png");
expect(details.fileQuality).toBe("standard");
expect(details.imageQuality).toBe("standard");
expect(details.fileScale).toBe(2);
expect(details.imageScale).toBe(2);
expect(details.fileMaxWidth).toBe(960);
expect(details.imageMaxWidth).toBe(960);
expect(details.viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
it("renders PDF output when fileFormat is pdf", async () => {
const screenshotter = createPdfScreenshotter({
assertOutputPath: (outputPath) => {
expect(outputPath).toMatch(/preview\.pdf$/);
},
});
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2b", {
before: "one\n",
after: "two\n",
mode: "image",
fileFormat: "pdf",
});
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
expect((result.details as Record<string, unknown>).format).toBe("pdf");
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("accepts mode=file as an alias for file artifact rendering", async () => {
const screenshotter = createPngScreenshotter({
assertOutputPath: (outputPath) => {
expect(outputPath).toMatch(/preview\.png$/);
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2c", {
before: "one\n",
after: "two\n",
mode: "file",
});
expectArtifactOnlyFileResult(screenshotter, result);
expect(requireString(readDetails(result).artifactId, "artifactId")).toMatch(/^[a-f0-9]{20}$/u);
expect(requireString(readDetails(result).expiresAt, "expiresAt")).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u,
);
});
it("honors ttlSeconds for artifact-only file output", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
try {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2c-ttl", {
before: "one\n",
after: "two\n",
mode: "file",
ttlSeconds: "1",
});
const filePath = requireString(readDetails(result).filePath, "filePath");
await fs.access(filePath);
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
await expectFsEnoent(fs.stat(filePath));
} finally {
vi.useRealTimers();
}
});
it("caps artifact-only ttlSeconds that bypass schema validation", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
try {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2c-ttl-cap", {
before: "one\n",
after: "two\n",
mode: "file",
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
expect(Date.parse(requireString(readDetails(result).expiresAt, "expiresAt"))).toBe(
now.getTime() + 21_600_000,
);
} finally {
vi.useRealTimers();
}
});
it("uses default ttlSeconds when tool input omits ttlSeconds", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
try {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
ttlSeconds: 60,
});
const result = await tool.execute?.("tool-2c-default-ttl", {
before: "one\n",
after: "two\n",
mode: "file",
});
const filePath = (result.details as Record<string, unknown>).filePath as string;
const stat = await fs.stat(filePath);
expect(stat.isFile()).toBe(true);
vi.setSystemTime(new Date(now.getTime() + 61_000));
await store.cleanupExpired();
await expectFsEnoent(fs.stat(filePath));
} finally {
vi.useRealTimers();
}
});
it("accepts image* tool options for backward compatibility", async () => {
const screenshotter = createPngScreenshotter({
assertImage: (image) => {
expect(image.qualityPreset).toBe("hq");
expect(image.scale).toBe(2.4);
expect(image.maxWidth).toBe(1100);
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2legacy", {
before: "one\n",
after: "two\n",
mode: "file",
imageQuality: "hq",
imageScale: "2.4",
imageMaxWidth: "1100",
});
expect((result.details as Record<string, unknown>).fileQuality).toBe("hq");
expect((result.details as Record<string, unknown>).fileScale).toBe(2.4);
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
});
it("accepts deprecated format alias for fileFormat", async () => {
const screenshotter = createPdfScreenshotter();
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2format", {
before: "one\n",
after: "two\n",
mode: "file",
format: "pdf",
});
expect((result.details as Record<string, unknown>).fileFormat).toBe("pdf");
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("honors defaults.mode=file when mode is omitted", async () => {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "file",
});
const result = await tool.execute?.("tool-2d", {
before: "one\n",
after: "two\n",
});
expectArtifactOnlyFileResult(screenshotter, result);
});
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter: {
screenshotHtml: vi.fn(async () => {
throw new Error("browser missing");
}),
},
});
const result = await tool.execute?.("tool-3", {
before: "one\n",
after: "two\n",
mode: "both",
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("File rendering failed");
expect((result.details as Record<string, unknown>).fileError).toBe("browser missing");
expect((result.details as Record<string, unknown>).imageError).toBe("browser missing");
});
it("rejects invalid base URLs as tool input errors", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
await expect(
tool.execute?.("tool-4", {
before: "one\n",
after: "two\n",
mode: "view",
baseUrl: "javascript:alert(1)",
}),
).rejects.toThrow("Invalid baseUrl");
});
it("rejects oversized patch payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
await expect(
tool.execute?.("tool-oversize-patch", {
patch: "x".repeat(2_100_000),
mode: "view",
}),
).rejects.toThrow("patch exceeds maximum size");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-oversize-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
wordWrap: false,
background: false,
fontFamily: "JetBrains Mono",
fontSize: 17,
},
context: {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
});
const result = await tool.execute?.("tool-5", {
before: "one\n",
after: "two\n",
path: "README.md",
});
expect(readTextContent(result, 0)).toContain("Diff viewer ready.");
expect((result.details as Record<string, unknown>).mode).toBe("view");
expect((result.details as Record<string, unknown>).context).toEqual({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
});
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
const id = extractViewerArtifactId(viewerPath);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="light"');
expect(html).toContain("--diffs-font-size: 17px;");
expect(html).toContain("JetBrains Mono");
});
it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = createPngScreenshotter({
assertHtml: (html) => {
expect(html).toContain("../../assets/viewer.js");
},
assertImage: (image) => {
expect(image.format).toBe("png");
expect(image.qualityPreset).toBe("print");
expect(image.scale).toBe(2.75);
expect(image.maxWidth).toBe(1320);
},
});
const tool = createToolWithScreenshotter(store, screenshotter, {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1180,
});
const result = await tool.execute?.("tool-6", {
before: "one\n",
after: "two\n",
mode: "both",
theme: "dark",
layout: "unified",
fileQuality: "print",
fileScale: 2.75,
fileMaxWidth: 1320,
});
expect((result.details as Record<string, unknown>).mode).toBe("both");
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect((result.details as Record<string, unknown>).format).toBe("png");
expect((result.details as Record<string, unknown>).fileQuality).toBe("print");
expect((result.details as Record<string, unknown>).fileScale).toBe(2.75);
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
const id = extractViewerArtifactId(viewerPath);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="dark"');
});
it("routes tool context into artifact details for file mode", async () => {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, DEFAULT_DIFFS_TOOL_DEFAULTS, {
agentId: "reviewer",
sessionId: "session-456",
messageChannel: "telegram",
agentAccountId: "work",
});
const result = await tool.execute?.("tool-context-file", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect((result.details as Record<string, unknown>).context).toEqual({
agentId: "reviewer",
sessionId: "session-456",
messageChannel: "telegram",
agentAccountId: "work",
});
});
});
function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
return createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig,
runtime: {} as OpenClawPluginApi["runtime"],
});
}
function createToolWithScreenshotter(
store: DiffArtifactStore,
screenshotter: DiffScreenshotter,
defaults = DEFAULT_DIFFS_TOOL_DEFAULTS,
context: OpenClawPluginToolContext = {
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
) {
return createDiffsTool({
api: createApi(),
store,
defaults,
screenshotter,
context,
});
}
function expectArtifactOnlyFileResult(
screenshotter: DiffScreenshotter,
result: { details?: unknown } | null | undefined,
) {
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect((result!.details as Record<string, unknown>).mode).toBe("file");
expect((result!.details as Record<string, unknown>).viewerUrl).toBeUndefined();
}
function createPngScreenshotter(
params: {
assertHtml?: (html: string) => void;
assertImage?: (image: DiffRenderOptions["image"]) => void;
assertOutputPath?: (outputPath: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: DiffRenderOptions["image"];
}) => {
params.assertHtml?.(html);
params.assertImage?.(image);
params.assertOutputPath?.(outputPath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
);
return {
screenshotHtml,
};
}
function createPdfScreenshotter(
params: {
assertOutputPath?: (outputPath: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({ outputPath, image }: { outputPath: string; image: DiffRenderOptions["image"] }) => {
expect(image.format).toBe("pdf");
params.assertOutputPath?.(outputPath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
);
return { screenshotHtml };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readDetails(result: unknown): Record<string, unknown> {
const details = (result as { details?: unknown } | null | undefined)?.details;
if (!isRecord(details)) {
throw new Error("expected diffs tool result details");
}
return details;
}
function extractViewerArtifactId(viewerPath: string): string {
let previousSegment: string | undefined;
let currentSegment: string | undefined;
for (const segment of viewerPath.split("/")) {
if (segment.length === 0) {
continue;
}
previousSegment = currentSegment;
currentSegment = segment;
}
if (!previousSegment) {
throw new Error(`Missing artifact id in viewer path: ${viewerPath}`);
}
return previousSegment;
}
function readParametersProperties(parameters: unknown): Record<string, unknown> {
if (isRecord(parameters) && isRecord(parameters.properties)) {
return parameters.properties;
}
throw new Error("expected diffs tool parameter properties");
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`expected ${label}`);
}
return value;
}
async function expectFsEnoent(promise: Promise<unknown>): Promise<void> {
try {
await promise;
} catch (error) {
expect((error as { code?: unknown }).code).toBe("ENOENT");
return;
}
throw new Error("expected ENOENT");
}
function readTextContent(result: unknown, index: number): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
?.content;
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}

View File

@@ -0,0 +1,552 @@
// Diffs plugin module implements tool behavior.
import fs from "node:fs/promises";
import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import type { Static } from "typebox";
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type {
DiffArtifactContext,
DiffRenderOptions,
DiffRenderTarget,
DiffToolDefaults,
} from "./types.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffInput,
type DiffImageQualityPreset,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
const MAX_BEFORE_AFTER_BYTES = 512 * 1024;
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
const MAX_TITLE_BYTES = 1_024;
const MAX_PATH_BYTES = 2_048;
const MAX_LANG_BYTES = 128;
const MAX_DIFF_ARTIFACT_TTL_SECONDS = 21_600;
const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
after: Type.Optional(Type.String({ description: "Updated text content." })),
patch: Type.Optional(
Type.String({
description: "Unified diff or patch text.",
maxLength: MAX_PATCH_BYTES,
}),
),
path: Type.Optional(
Type.String({
description: "Display path for before/after input.",
maxLength: MAX_PATH_BYTES,
}),
),
lang: Type.Optional(
Type.String({
description: "Optional language override for before/after input.",
maxLength: MAX_LANG_BYTES,
}),
),
title: Type.Optional(
Type.String({
description: "Optional title for the rendered diff.",
maxLength: MAX_TITLE_BYTES,
}),
),
mode: Type.Optional(
stringEnum(DIFF_MODES, {
description:
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
}),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
layout: Type.Optional(
stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }),
),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "File quality preset: standard, hq, or print.",
}),
),
fileFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }),
),
fileScale: optionalFiniteNumberSchema({
description: "Optional rendered-file device scale factor override (1-4).",
minimum: 1,
maximum: 4,
}),
fileMaxWidth: optionalFiniteNumberSchema({
description: "Optional rendered-file max width in CSS pixels (640-2400).",
minimum: 640,
maximum: 2400,
}),
/** @deprecated Use fileQuality. */
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "Deprecated alias for fileQuality.",
deprecated: true,
}),
),
/** @deprecated Use fileFormat. */
imageFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, {
description: "Deprecated alias for fileFormat.",
deprecated: true,
}),
),
/** @deprecated Use fileScale. */
imageScale: optionalFiniteNumberSchema({
description: "Deprecated alias for fileScale.",
deprecated: true,
minimum: 1,
maximum: 4,
}),
/** @deprecated Use fileMaxWidth. */
imageMaxWidth: optionalFiniteNumberSchema({
description: "Deprecated alias for fileMaxWidth.",
deprecated: true,
minimum: 640,
maximum: 2400,
}),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
ttlSeconds: optionalFiniteNumberSchema({
description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.",
minimum: 1,
maximum: MAX_DIFF_ARTIFACT_TTL_SECONDS,
}),
baseUrl: Type.Optional(
Type.String({
description:
"Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.",
}),
),
},
{ additionalProperties: false },
);
type DiffsToolParams = Static<typeof DiffsToolSchema>;
type DiffsToolRawParams = DiffsToolParams & {
/** @deprecated Use fileFormat. */
format?: DiffOutputFormat;
};
export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
defaults: DiffToolDefaults;
viewerBaseUrl?: string;
languagePackAvailable?: boolean;
screenshotter?: DiffScreenshotter;
context?: OpenClawPluginToolContext;
}): AnyAgentTool {
return {
name: "diffs",
label: "Diffs",
description:
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolRawParams;
const rawRecord = rawParams as Record<string, unknown>;
const artifactContext = buildArtifactContext(params.context);
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlSeconds =
readFiniteNumberParam(rawRecord, "ttlSeconds") ?? params.defaults.ttlSeconds;
const fileScale =
readFiniteNumberParam(rawRecord, "fileScale") ??
readFiniteNumberParam(rawRecord, "imageScale");
const fileMaxWidth =
readFiniteNumberParam(rawRecord, "fileMaxWidth") ??
readFiniteNumberParam(rawRecord, "imageMaxWidth");
const ttlMs = normalizeTtlMs(ttlSeconds);
const image = resolveDiffImageRenderOptions({
defaults: params.defaults,
fileFormat: normalizeOutputFormat(
toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format,
),
fileQuality: normalizeFileQuality(toolParams.fileQuality ?? toolParams.imageQuality),
fileScale,
fileMaxWidth,
});
const renderTarget = resolveRenderTarget(mode);
const rendered = await renderDiffDocument(
input,
{
presentation: {
...params.defaults,
layout,
theme,
},
image,
expandUnchanged,
languagePackAvailable: params.languagePackAvailable,
},
renderTarget,
);
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
if (isArtifactOnlyMode(mode)) {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
html: requireRenderedHtml(rendered.imageHtml, "image"),
theme,
image,
ttlMs,
context: artifactContext,
});
return {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
}),
},
],
details: buildArtifactDetails({
baseDetails: {
...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}),
...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}),
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
},
artifactFile,
image,
}),
};
}
const artifact = await params.store.createArtifact({
html: requireRenderedHtml(rendered.html, "viewer"),
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
ttlMs,
context: artifactContext,
});
const viewerUrl = buildViewerUrl({
config: params.api.config,
viewerPath: artifact.viewerPath,
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
});
const baseDetails = {
artifactId: artifact.id,
viewerUrl,
viewerPath: artifact.viewerPath,
title: artifact.title,
expiresAt: artifact.expiresAt,
inputKind: artifact.inputKind,
fileCount: artifact.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
};
if (mode === "view") {
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}`,
},
],
details: baseDetails,
};
}
try {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
artifactId: artifact.id,
html: requireRenderedHtml(rendered.imageHtml, "image"),
theme,
image,
});
await params.store.updateFilePath(artifact.id, artifactFile.path);
return {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
viewerUrl,
}),
},
],
details: buildArtifactDetails({
baseDetails,
artifactFile,
image,
}),
};
} catch (error) {
if (mode === "both") {
const errorMessage = formatErrorMessage(error);
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}\nFile rendering failed: ${errorMessage}`,
},
],
details: {
...baseDetails,
fileError: errorMessage,
imageError: errorMessage,
},
};
}
throw error;
}
},
};
}
function normalizeFileQuality(
fileQuality: DiffImageQualityPreset | undefined,
): DiffImageQualityPreset | undefined {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined;
}
function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined {
return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined;
}
function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" {
return mode === "image" || mode === "file";
}
function resolveRenderTarget(mode: DiffMode): DiffRenderTarget {
if (mode === "view") {
return "viewer";
}
if (isArtifactOnlyMode(mode)) {
return "image";
}
return "both";
}
function requireRenderedHtml(html: string | undefined, target: DiffRenderTarget): string {
if (html !== undefined) {
return html;
}
throw new Error(`Missing ${target} render output.`);
}
function buildArtifactDetails(params: {
baseDetails: Record<string, unknown>;
artifactFile: { path: string; bytes: number };
image: DiffRenderOptions["image"];
}) {
return {
...params.baseDetails,
filePath: params.artifactFile.path,
imagePath: params.artifactFile.path,
path: params.artifactFile.path,
fileBytes: params.artifactFile.bytes,
imageBytes: params.artifactFile.bytes,
format: params.image.format,
fileFormat: params.image.format,
fileQuality: params.image.qualityPreset,
imageQuality: params.image.qualityPreset,
fileScale: params.image.scale,
imageScale: params.image.scale,
fileMaxWidth: params.image.maxWidth,
imageMaxWidth: params.image.maxWidth,
};
}
function buildFileArtifactMessage(params: {
format: DiffOutputFormat;
filePath: string;
viewerUrl?: string;
}): string {
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
lines.push("Use the `message` tool with `path` or `filePath` to send this file.");
return lines.join("\n");
}
async function renderDiffArtifactFile(params: {
screenshotter: DiffScreenshotter;
store: DiffArtifactStore;
artifactId?: string;
html: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
ttlMs?: number;
context?: DiffArtifactContext;
}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> {
const standaloneArtifact = params.artifactId
? undefined
: await params.store.createStandaloneFileArtifact({
format: params.image.format,
ttlMs: params.ttlMs,
context: params.context,
});
const outputPath = params.artifactId
? params.store.allocateFilePath(params.artifactId, params.image.format)
: standaloneArtifact!.filePath;
await params.screenshotter.screenshotHtml({
html: params.html,
outputPath,
theme: params.theme,
image: params.image,
});
const stats = await fs.stat(outputPath);
return {
path: outputPath,
bytes: stats.size,
...(standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {}),
...(standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}),
};
}
function buildArtifactContext(
context: OpenClawPluginToolContext | undefined,
): DiffArtifactContext | undefined {
if (!context) {
return undefined;
}
const artifactContext = {
agentId: normalizeOptionalString(context.agentId),
sessionId: normalizeOptionalString(context.sessionId),
messageChannel: normalizeOptionalString(context.messageChannel),
agentAccountId: normalizeOptionalString(context.agentAccountId),
};
return Object.values(artifactContext).some((value) => value !== undefined)
? artifactContext
: undefined;
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
const after = params.after;
if (patch) {
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
if (before !== undefined || after !== undefined) {
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
}
const title = params.title?.trim();
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return {
kind: "patch",
patch,
title,
};
}
if (before === undefined || after === undefined) {
throw new PluginToolInputError("Provide patch or both before and after text.");
}
assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES);
assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES);
const path = normalizeOptionalString(params.path);
const lang = normalizeOptionalString(params.lang);
const title = normalizeOptionalString(params.title);
if (path) {
assertMaxBytes(path, "path", MAX_PATH_BYTES);
}
if (lang) {
assertMaxBytes(lang, "lang", MAX_LANG_BYTES);
}
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return {
kind: "before_after",
before,
after,
path,
lang,
title,
};
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}
function normalizeBaseUrl(baseUrl?: string): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
try {
return normalizeViewerBaseUrl(normalized);
} catch {
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
}
}
function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : fallback;
}
function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : fallback;
}
function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback;
}
function normalizeTtlMs(ttlSeconds?: number): number | undefined {
if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) {
return undefined;
}
return Math.floor(Math.min(Math.max(ttlSeconds, 1), MAX_DIFF_ARTIFACT_TTL_SECONDS) * 1000);
}
class PluginToolInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}

View File

@@ -0,0 +1,130 @@
// Diffs type declarations define plugin contracts.
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
export const DIFF_LAYOUTS = ["unified", "split"] as const;
export const DIFF_MODES = ["view", "image", "file", "both"] as const;
export const DIFF_THEMES = ["light", "dark"] as const;
export const DIFF_INDICATORS = ["bars", "classic", "none"] as const;
export const DIFF_IMAGE_QUALITY_PRESETS = ["standard", "hq", "print"] as const;
export const DIFF_OUTPUT_FORMATS = ["png", "pdf"] as const;
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type DiffIndicators = (typeof DIFF_INDICATORS)[number];
export type DiffImageQualityPreset = (typeof DIFF_IMAGE_QUALITY_PRESETS)[number];
export type DiffOutputFormat = (typeof DIFF_OUTPUT_FORMATS)[number];
export type DiffRenderTarget = "viewer" | "image" | "both";
type DiffPresentationDefaults = {
fontFamily: string;
fontSize: number;
lineSpacing: number;
layout: DiffLayout;
showLineNumbers: boolean;
diffIndicators: DiffIndicators;
wordWrap: boolean;
background: boolean;
theme: DiffTheme;
};
export type DiffFileDefaults = {
fileFormat: DiffOutputFormat;
fileQuality: DiffImageQualityPreset;
fileScale: number;
fileMaxWidth: number;
};
export type DiffToolDefaults = DiffPresentationDefaults &
DiffFileDefaults & {
mode: DiffMode;
ttlSeconds: number;
};
type BeforeAfterDiffInput = {
kind: "before_after";
before: string;
after: string;
path?: string;
lang?: string;
title?: string;
};
type PatchDiffInput = {
kind: "patch";
patch: string;
title?: string;
};
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
presentation: DiffPresentationDefaults;
image: {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
};
expandUnchanged: boolean;
languagePackAvailable?: boolean;
};
export type DiffViewerOptions = {
theme: {
light: "pierre-light";
dark: "pierre-dark";
};
diffStyle: DiffLayout;
diffIndicators: DiffIndicators;
disableLineNumbers: boolean;
expandUnchanged: boolean;
themeType: DiffTheme;
backgroundEnabled: boolean;
overflow: "scroll" | "wrap";
unsafeCSS: string;
};
export type DiffViewerPayload = {
prerenderedHTML: string;
options: DiffViewerOptions;
langs: SupportedLanguages[];
oldFile?: FileContents;
newFile?: FileContents;
fileDiff?: FileDiffMetadata;
};
export type RenderedDiffDocument = {
html?: string;
imageHtml?: string;
title: string;
fileCount: number;
inputKind: DiffInput["kind"];
viewerRuntime: "base" | "language-pack";
};
export type DiffArtifactContext = {
agentId?: string;
sessionId?: string;
messageChannel?: string;
agentAccountId?: string;
};
export type DiffArtifactMeta = {
id: string;
token: string;
createdAt: string;
expiresAt: string;
title: string;
inputKind: DiffInput["kind"];
fileCount: number;
viewerPath: string;
htmlPath: string;
context?: DiffArtifactContext;
filePath?: string;
imagePath?: string;
};
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
export const DIFF_ARTIFACT_TOKEN_PATTERN = /^[0-9a-f]{48}$/;

View File

@@ -0,0 +1,61 @@
// Diffs plugin module implements url behavior.
import type { OpenClawConfig } from "../api.js";
const DEFAULT_GATEWAY_PORT = 18789;
type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
export function buildViewerUrl(params: {
config: OpenClawConfig;
viewerPath: string;
baseUrl?: string;
}): string {
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
const viewerPath = params.viewerPath.startsWith("/")
? params.viewerPath
: `/${params.viewerPath}`;
const parsedBase = new URL(normalizedBase);
const basePath = parsedBase.pathname === "/" ? "" : parsedBase.pathname.replace(/\/+$/, "");
parsedBase.pathname = `${basePath}${viewerPath}`;
parsedBase.search = "";
parsedBase.hash = "";
return parsedBase.toString();
}
export function normalizeViewerBaseUrl(
raw: string,
fieldName: ViewerBaseUrlFieldName = "baseUrl",
): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid ${fieldName}: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${fieldName} must use http or https: ${raw}`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${fieldName} must not include query/hash: ${raw}`);
}
parsed.search = "";
parsed.hash = "";
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
return withoutTrailingSlash;
}
function resolveGatewayBaseUrl(config: OpenClawConfig): string {
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
const port =
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
const customHost = config.gateway?.customBindHost?.trim();
if (config.gateway?.bind === "custom" && customHost) {
return `${scheme}://${customHost}:${port}`;
}
// Viewer links are used by local canvas/clients; default to loopback to avoid
// container/bridge interfaces that are often unreachable from the caller.
return `${scheme}://127.0.0.1:${port}`;
}

View File

@@ -0,0 +1,190 @@
// Diffs plugin module implements viewer assets behavior.
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
export const LANGUAGE_PACK_VIEWER_ASSET_PREFIX = "/plugins/diffs-language-pack/assets/";
export const LANGUAGE_PACK_VIEWER_LOADER_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer.js`;
export const LANGUAGE_PACK_VIEWER_RUNTIME_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer-runtime.js`;
const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js";
const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
"./assets/viewer-runtime.js",
"../assets/viewer-runtime.js",
] as const;
const LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
"../../diffs-language-pack/assets/viewer-runtime.js",
"../diffs-language-pack/assets/viewer-runtime.js",
] as const;
type ServedViewerAsset = {
body: string | Buffer;
contentType: string;
};
type RuntimeAssetCache = {
mtimeMs: number;
runtimeBody: Buffer;
loaderBody: string;
};
let runtimeAssetCache: RuntimeAssetCache | null = null;
let languagePackRuntimeAssetCache: RuntimeAssetCache | null = null;
type ViewerRuntimeFileUrlParams = {
baseUrl?: string | URL;
stat?: (path: string) => Promise<unknown>;
};
function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
export async function resolveViewerRuntimeFileUrl(
params: ViewerRuntimeFileUrlParams = {},
): Promise<URL> {
const baseUrl = params.baseUrl ?? import.meta.url;
const stat = params.stat ?? ((path: string) => fs.stat(path));
let missingFileError: NodeJS.ErrnoException | null = null;
for (const relativePath of VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS) {
const candidateUrl = new URL(relativePath, baseUrl);
try {
await stat(fileURLToPath(candidateUrl));
return candidateUrl;
} catch (error) {
if (isMissingFileError(error)) {
missingFileError = error;
continue;
}
throw error;
}
}
if (missingFileError) {
throw missingFileError;
}
throw new Error("viewer runtime asset candidates were not checked");
}
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
return null;
}
const assets = await loadViewerAssets();
if (pathname === VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
if (pathname === VIEWER_RUNTIME_PATH) {
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
return null;
}
export async function getServedLanguagePackViewerAsset(
pathname: string,
): Promise<ServedViewerAsset | null> {
if (
pathname !== LANGUAGE_PACK_VIEWER_LOADER_PATH &&
pathname !== LANGUAGE_PACK_VIEWER_RUNTIME_PATH
) {
return null;
}
let assets: RuntimeAssetCache;
try {
const runtimeUrl = await resolveRuntimeFileUrl(LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS);
assets = await loadRuntimeAssets({
runtimeUrl,
cache: languagePackRuntimeAssetCache,
updateCache: (cache) => {
languagePackRuntimeAssetCache = cache;
},
});
} catch (error) {
if (isMissingFileError(error)) {
return null;
}
throw error;
}
if (pathname === LANGUAGE_PACK_VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
const runtimeUrl = await resolveViewerRuntimeFileUrl();
return loadRuntimeAssets({
runtimeUrl,
cache: runtimeAssetCache,
updateCache: (cache) => {
runtimeAssetCache = cache;
},
});
}
async function loadRuntimeAssets(params: {
cache: RuntimeAssetCache | null;
runtimeUrl: URL;
updateCache(cache: RuntimeAssetCache): void;
}): Promise<RuntimeAssetCache> {
const runtimePath = fileURLToPath(params.runtimeUrl);
const runtimeStat = await fs.stat(runtimePath);
if (params.cache && params.cache.mtimeMs === runtimeStat.mtimeMs) {
return params.cache;
}
const runtimeBody = await fs.readFile(runtimePath);
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
const cache = {
mtimeMs: runtimeStat.mtimeMs,
runtimeBody,
loaderBody: `import "${VIEWER_RUNTIME_RELATIVE_IMPORT_PATH}?v=${hash}";\n`,
};
params.updateCache(cache);
return cache;
}
async function resolveRuntimeFileUrl(relativePaths: readonly string[]): Promise<URL> {
let missingFileError: NodeJS.ErrnoException | null = null;
for (const relativePath of relativePaths) {
const candidateUrl = new URL(relativePath, import.meta.url);
try {
await fs.stat(fileURLToPath(candidateUrl));
return candidateUrl;
} catch (error) {
if (isMissingFileError(error)) {
missingFileError = error;
continue;
}
throw error;
}
}
if (missingFileError) {
throw missingFileError;
}
throw new Error("viewer runtime asset candidates were not checked");
}

View File

@@ -0,0 +1,490 @@
/* @vitest-environment jsdom */
import { readFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const disableAutoStartKey = Symbol.for("openclaw.diffs.disableAutoStart");
(globalThis as typeof globalThis & Record<symbol, unknown>)[disableAutoStartKey] = true;
const VIEWER_CLIENT_SRC = readFileSync(
path.join(process.cwd(), "extensions/diffs/src/viewer-client.ts"),
"utf8",
);
const XSS_PATTERNS = ["onerror", "<script", "onclick", "javascript:", "onload"];
const {
fileDiffHydrateMock,
fileDiffRerenderMock,
fileDiffSetOptionsMock,
preloadHighlighterMock,
} = vi.hoisted(() => ({
fileDiffHydrateMock: vi.fn(),
fileDiffRerenderMock: vi.fn(),
fileDiffSetOptionsMock: vi.fn(),
preloadHighlighterMock: vi.fn(async () => undefined),
}));
vi.mock("@pierre/diffs", () => ({
FileDiff: class {
hydrate(params: unknown) {
return fileDiffHydrateMock(params);
}
rerender() {
return fileDiffRerenderMock();
}
setOptions(params: unknown) {
return fileDiffSetOptionsMock(params);
}
},
preloadHighlighter: preloadHighlighterMock,
}));
const viewerPayload = JSON.stringify({
prerenderedHTML: "<div>diff</div>",
options: {
theme: { light: "pierre-light", dark: "pierre-dark" },
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["text"],
oldFile: { name: "a.ts", lang: "text", contents: "old" },
newFile: { name: "a.ts", lang: "text", contents: "new" },
});
function renderCard(payloadOverride?: string): void {
const payload = payloadOverride ?? viewerPayload;
document.body.insertAdjacentHTML(
"beforeend",
`<section class="oc-diff-card">
<div data-openclaw-diff-host></div>
<script type="application/json" data-openclaw-diff-payload>${payload}</script>
</section>`,
);
}
describe("createToolbarButton icon safety", () => {
it("toolbarIconSvg map exists and has exactly 8 icon names", () => {
const requiredNames = [
"split",
"unified",
"wrap-on",
"wrap-off",
"background-on",
"background-off",
"theme-dark",
"theme-light",
] as const;
for (const name of requiredNames) {
expect(
VIEWER_CLIENT_SRC.includes(name + ":") || VIEWER_CLIENT_SRC.includes(`"${name}"`),
`icon "${name}" should exist in toolbarIconSvg`,
).toBe(true);
}
});
it("no iconMarkup: string parameter exists", () => {
expect(VIEWER_CLIENT_SRC.includes("iconMarkup: string")).toBe(false);
});
it("innerHTML reads only from toolbarIconSvg lookup", () => {
expect(VIEWER_CLIENT_SRC.includes("button.innerHTML = toolbarIconSvg[params.icon]")).toBe(true);
});
it("SVG strings in toolbarIconSvg contain no XSS patterns", () => {
for (const pattern of XSS_PATTERNS) {
expect(VIEWER_CLIENT_SRC.includes(pattern), `source must not contain "${pattern}"`).toBe(
false,
);
}
});
it("old icon functions are removed", () => {
const removedFunctions = [
"function splitIcon(",
"function unifiedIcon(",
"function wrapIcon(",
"function backgroundIcon(",
"function themeIcon(",
];
for (const fn of removedFunctions) {
expect(VIEWER_CLIENT_SRC.includes(fn), `"${fn}" should be removed`).toBe(false);
}
});
});
describe("hydrateViewer", () => {
beforeEach(() => {
document.body.innerHTML = "";
delete document.documentElement.dataset.openclawDiffsError;
delete document.documentElement.dataset.openclawDiffsReady;
vi.clearAllMocks();
});
it("continues hydrating later cards when one card throws", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
renderCard();
renderCard();
fileDiffHydrateMock.mockImplementationOnce(() => {
throw new Error("broken card");
});
const { controllers, hydrateViewer } = await import("./viewer-client.js");
controllers.splice(0);
await hydrateViewer();
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
expect(controllers).toHaveLength(1);
expect(warn).toHaveBeenCalledWith(
"Skipping diff card that failed to hydrate",
expect.any(Error),
);
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
warn.mockRestore();
});
it("does not retain controllers when initial state application throws", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
renderCard();
renderCard();
fileDiffSetOptionsMock.mockImplementationOnce(() => {
throw new Error("broken options");
});
const { controllers, hydrateViewer } = await import("./viewer-client.js");
controllers.splice(0);
await hydrateViewer();
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
expect(fileDiffSetOptionsMock).toHaveBeenCalledTimes(2);
expect(controllers).toHaveLength(1);
expect(warn).toHaveBeenCalledWith(
"Skipping diff card that failed to hydrate",
expect.any(Error),
);
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
warn.mockRestore();
});
it("replaces stale controllers when hydrating the current cards again", async () => {
renderCard();
const { controllers, hydrateViewer } = await import("./viewer-client.js");
controllers.splice(0);
await hydrateViewer();
expect(controllers).toHaveLength(1);
const firstController = controllers[0];
document.body.innerHTML = "";
renderCard();
await hydrateViewer();
expect(controllers).toHaveLength(1);
expect(controllers[0]).not.toBe(firstController);
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
});
});
describe("viewerState initialization", () => {
beforeEach(() => {
document.body.innerHTML = "";
delete document.documentElement.dataset.openclawDiffsError;
delete document.documentElement.dataset.openclawDiffsReady;
delete document.body.dataset.theme;
vi.clearAllMocks();
});
it("seeds viewerState from firstPayload options and syncs document theme", async () => {
const customPayload = JSON.stringify({
prerenderedHTML: "<div>diff</div>",
options: {
theme: { light: "pierre-light", dark: "pierre-dark" },
diffStyle: "split",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "light",
backgroundEnabled: false,
overflow: "scroll",
unsafeCSS: "",
},
langs: ["text"],
oldFile: { name: "a.ts", lang: "text", contents: "old" },
newFile: { name: "a.ts", lang: "text", contents: "new" },
});
renderCard(customPayload);
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
expect(document.body.dataset.theme).toBe("light");
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts.diffStyle).toBe("split");
expect(opts.themeType).toBe("light");
expect(opts.overflow).toBe("scroll");
expect(opts.disableBackground).toBe(true);
});
it("defaults viewerState to dark/unified/wrap/background when firstPayload uses defaults", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
expect(document.body.dataset.theme).toBe("dark");
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts.diffStyle).toBe("unified");
expect(opts.themeType).toBe("dark");
expect(opts.overflow).toBe("wrap");
expect(opts.disableBackground).toBe(false);
});
it("preloadHighlighter receives merged language set from all cards", async () => {
const payload1 = JSON.stringify({
prerenderedHTML: "<div>diff1</div>",
options: {
theme: { light: "pierre-light", dark: "pierre-dark" },
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["typescript"],
oldFile: { name: "a.ts", lang: "typescript", contents: "old" },
newFile: { name: "a.ts", lang: "typescript", contents: "new" },
});
const payload2 = JSON.stringify({
prerenderedHTML: "<div>diff2</div>",
options: {
theme: { light: "pierre-light", dark: "pierre-dark" },
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["python"],
oldFile: { name: "b.py", lang: "python", contents: "old" },
newFile: { name: "b.py", lang: "python", contents: "new" },
});
renderCard(payload1);
renderCard(payload2);
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const preloadArg = (preloadHighlighterMock.mock.calls as unknown[][])[0]?.[0] as
| { langs: string[]; themes: string[] }
| undefined;
expect(preloadArg).toBeDefined();
expect(preloadArg!.langs).toContain("typescript");
expect(preloadArg!.langs).toContain("python");
expect(preloadArg!.themes).toEqual(["pierre-light", "pierre-dark"]);
});
});
describe("toolbar button toggles", () => {
beforeEach(() => {
document.body.innerHTML = "";
delete document.body.dataset.theme;
vi.clearAllMocks();
});
it("layout toggle switches between unified and split", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts1.diffStyle).toBe("unified");
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
const toolbar = renderHeaderMetadata();
const buttons = toolbar.querySelectorAll("button");
buttons[0].click();
expect(fileDiffRerenderMock).toHaveBeenCalled();
const opts2 = fileDiffSetOptionsMock.mock.calls[
fileDiffSetOptionsMock.mock.calls.length - 1
]?.[0] as Record<string, unknown>;
expect(opts2.diffStyle).toBe("split");
});
it("theme toggle switches between dark and light", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts1.themeType).toBe("dark");
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
const toolbar = renderHeaderMetadata();
const buttons = toolbar.querySelectorAll("button");
buttons[3].click();
const lastOpts = fileDiffSetOptionsMock.mock.calls[
fileDiffSetOptionsMock.mock.calls.length - 1
]?.[0] as Record<string, unknown>;
expect(lastOpts.themeType).toBe("light");
expect(document.body.dataset.theme).toBe("light");
});
it("wrap toggle switches between wrap and scroll", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts1.overflow).toBe("wrap");
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
const toolbar = renderHeaderMetadata();
const buttons = toolbar.querySelectorAll("button");
buttons[1].click();
const lastOpts = fileDiffSetOptionsMock.mock.calls[
fileDiffSetOptionsMock.mock.calls.length - 1
]?.[0] as Record<string, unknown>;
expect(lastOpts.overflow).toBe("scroll");
});
it("background toggle inverts disableBackground", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(opts1.disableBackground).toBe(false);
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
const toolbar = renderHeaderMetadata();
const buttons = toolbar.querySelectorAll("button");
buttons[2].click();
const lastOpts = fileDiffSetOptionsMock.mock.calls[
fileDiffSetOptionsMock.mock.calls.length - 1
]?.[0] as Record<string, unknown>;
expect(lastOpts.disableBackground).toBe(true);
});
});
describe("ensureShadowRoot", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("attaches shadow root from template and removes template element", async () => {
renderCard();
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
const template = document.createElement("template");
template.setAttribute("shadowrootmode", "open");
template.innerHTML = "<div>shadow content</div>";
host.append(template);
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
expect(host.shadowRoot).toBeDefined();
expect(host.shadowRoot!.querySelector("div")?.textContent).toBe("shadow content");
expect(host.querySelector("template")).toBeNull();
});
it("skips shadow root attachment when no template is present", async () => {
renderCard();
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
expect(host.shadowRoot).toBeNull();
expect(fileDiffHydrateMock).toHaveBeenCalled();
});
it("skips shadow root when already attached", async () => {
renderCard();
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
host.attachShadow({ mode: "open" });
host.shadowRoot!.innerHTML = "<span>existing</span>";
const template = document.createElement("template");
template.setAttribute("shadowrootmode", "open");
template.innerHTML = "<div>new content</div>";
host.append(template);
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
expect(host.shadowRoot!.querySelector("span")?.textContent).toBe("existing");
expect(host.querySelector("template")).not.toBeNull();
});
});
describe("getHydrateProps branching", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("passes fileDiff directly when payload has fileDiff", async () => {
const fileDiffPayload = JSON.stringify({
prerenderedHTML: "<div>diff</div>",
options: {
theme: { light: "pierre-light", dark: "pierre-dark" },
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["text"],
fileDiff: { name: "patch.diff", lang: "text", hunks: [] },
});
renderCard(fileDiffPayload);
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(hydrateArg.fileDiff).toEqual({ name: "patch.diff", lang: "text", hunks: [] });
expect(hydrateArg.oldFile).toBeUndefined();
expect(hydrateArg.newFile).toBeUndefined();
});
it("passes oldFile and newFile when payload has them without fileDiff", async () => {
renderCard();
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(hydrateArg.fileDiff).toBeUndefined();
expect(hydrateArg.oldFile).toEqual({ name: "a.ts", lang: "text", contents: "old" });
expect(hydrateArg.newFile).toEqual({ name: "a.ts", lang: "text", contents: "new" });
});
});

View File

@@ -0,0 +1,364 @@
// Diffs plugin module implements viewer client behavior.
import { FileDiff, preloadHighlighter } from "@pierre/diffs";
import type {
FileContents,
FileDiffMetadata,
FileDiffOptions,
SupportedLanguages,
} from "@pierre/diffs";
import { normalizeDiffViewerPayloadLanguages } from "./language-hints.js";
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
type ViewerState = {
theme: DiffTheme;
layout: DiffLayout;
backgroundEnabled: boolean;
wrapEnabled: boolean;
};
type DiffController = {
payload: DiffViewerPayload;
diff: FileDiff;
};
export const controllers: DiffController[] = [];
const viewerState: ViewerState = {
theme: "dark",
layout: "unified",
backgroundEnabled: true,
wrapEnabled: true,
};
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
const raw = element.textContent?.trim();
if (!raw) {
throw new Error("Diff payload was empty.");
}
return parseViewerPayloadJson(raw);
}
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
const cards: Array<{ host: HTMLElement; payload: DiffViewerPayload }> = [];
for (const card of document.querySelectorAll<HTMLElement>(".oc-diff-card")) {
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
if (!host || !payloadNode) {
continue;
}
try {
cards.push({ host, payload: parsePayload(payloadNode) });
} catch (error) {
console.warn("Skipping invalid diff payload", error);
}
}
return cards;
}
function ensureShadowRoot(host: HTMLElement): void {
if (host.shadowRoot) {
return;
}
const template = host.querySelector<HTMLTemplateElement>(
":scope > template[shadowrootmode='open']",
);
if (!template) {
return;
}
const shadowRoot = host.attachShadow({ mode: "open" });
shadowRoot.append(template.content.cloneNode(true));
template.remove();
}
function getHydrateProps(payload: DiffViewerPayload): {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
} {
if (payload.fileDiff) {
return { fileDiff: payload.fileDiff };
}
return {
oldFile: payload.oldFile,
newFile: payload.newFile,
};
}
type ToolbarIconName =
| "split"
| "unified"
| "wrap-on"
| "wrap-off"
| "background-on"
| "background-off"
| "theme-dark"
| "theme-light";
const toolbarIconSvg: Record<ToolbarIconName, string> = {
split: `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0"></path>
<path fill="currentColor" opacity="0.5" d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"></path>
</svg>`,
unified: `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10" clip-rule="evenodd"></path>
<path fill="currentColor" fill-rule="evenodd" opacity="0.5" d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
</svg>`,
"wrap-on": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" opacity="1" d="M3.868 3.449a1.21 1.21 0 0 0-.473-.329c-.274-.111-.623-.15-1.055-.076a3.5 3.5 0 0 0-.71.208c-.082.035-.16.077-.235.125l-.043.03v1.056l.168-.139c.15-.124.326-.225.527-.303.196-.074.4-.113.604-.113.188 0 .33.051.431.157.087.095.137.248.147.456l-.962.144c-.219.03-.41.086-.57.166a1.245 1.245 0 0 0-.398.311c-.103.125-.181.27-.229.426-.097.33-.093.68.011 1.008a1.096 1.096 0 0 0 .638.67c.155.063.328.093.528.093a1.25 1.25 0 0 0 .978-.441v.345h1.007V4.65c0-.255-.03-.484-.089-.681a1.423 1.423 0 0 0-.275-.52zm-.636 1.896v.236c0 .119-.018.231-.055.341a.745.745 0 0 1-.377.447.694.694 0 0 1-.512.027.454.454 0 0 1-.156-.094.389.389 0 0 1-.094-.139.474.474 0 0 1-.035-.186c0-.077.01-.147.024-.212a.33.33 0 0 1 .078-.141.436.436 0 0 1 .161-.109 1.3 1.3 0 0 1 .305-.073l.661-.097zm5.051-1.067a2.253 2.253 0 0 0-.244-.656 1.354 1.354 0 0 0-.436-.459 1.165 1.165 0 0 0-.642-.173 1.136 1.136 0 0 0-.69.223 1.33 1.33 0 0 0-.264.266V1H5.09v6.224h.918v-.281c.123.152.287.266.472.328.098.032.208.047.33.047.255 0 .483-.06.677-.177.192-.115.355-.278.486-.486a2.29 2.29 0 0 0 .293-.718 3.87 3.87 0 0 0 .096-.886 3.714 3.714 0 0 0-.078-.773zm-.86.758c0 .232-.02.439-.06.613-.036.172-.09.315-.159.424a.639.639 0 0 1-.233.237.582.582 0 0 1-.565.014.683.683 0 0 1-.21-.183.925.925 0 0 1-.142-.283A1.187 1.187 0 0 1 6 5.5v-.517c0-.164.02-.314.06-.447.036-.132.087-.242.156-.336a.668.668 0 0 1 .228-.208.584.584 0 0 1 .29-.071.554.554 0 0 1 .496.279c.063.099.108.214.143.354.031.143.05.306.05.482zM2.407 9.9a.913.913 0 0 1 .316-.239c.218-.1.547-.105.766-.018.104.042.204.1.32.184l.33.26V8.945l-.097-.062a1.932 1.932 0 0 0-.905-.215c-.308 0-.593.057-.846.168-.25.11-.467.27-.647.475-.18.21-.318.453-.403.717-.09.272-.137.57-.137.895 0 .289.043.561.13.808.086.249.211.471.373.652.161.185.361.333.597.441.232.104.493.155.778.155.233 0 .434-.028.613-.084.165-.05.322-.123.466-.217l.078-.061v-.889l-.2.095a.4.4 0 0 1-.076.026c-.05.017-.099.035-.128.049-.036.023-.227.09-.227.09-.06.024-.14.043-.218.059a.977.977 0 0 1-.599-.057.827.827 0 0 1-.306-.225 1.088 1.088 0 0 1-.205-.376 1.728 1.728 0 0 1-.076-.529c0-.21.028-.399.083-.56.054-.158.13-.294.22-.4zM14 6h-4V5h4.5l.5.5v6l-.5.5H7.879l2.07 2.071-.706.707-2.89-2.889v-.707l2.89-2.89L9.95 9l-2 2H14V6z"></path>
</svg>`,
"wrap-off": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" opacity="0.85" d="M3.868 3.449a1.21 1.21 0 0 0-.473-.329c-.274-.111-.623-.15-1.055-.076a3.5 3.5 0 0 0-.71.208c-.082.035-.16.077-.235.125l-.043.03v1.056l.168-.139c.15-.124.326-.225.527-.303.196-.074.4-.113.604-.113.188 0 .33.051.431.157.087.095.137.248.147.456l-.962.144c-.219.03-.41.086-.57.166a1.245 1.245 0 0 0-.398.311c-.103.125-.181.27-.229.426-.097.33-.093.68.011 1.008a1.096 1.096 0 0 0 .638.67c.155.063.328.093.528.093a1.25 1.25 0 0 0 .978-.441v.345h1.007V4.65c0-.255-.03-.484-.089-.681a1.423 1.423 0 0 0-.275-.52zm-.636 1.896v.236c0 .119-.018.231-.055.341a.745.745 0 0 1-.377.447.694.694 0 0 1-.512.027.454.454 0 0 1-.156-.094.389.389 0 0 1-.094-.139.474.474 0 0 1-.035-.186c0-.077.01-.147.024-.212a.33.33 0 0 1 .078-.141.436.436 0 0 1 .161-.109 1.3 1.3 0 0 1 .305-.073l.661-.097zm5.051-1.067a2.253 2.253 0 0 0-.244-.656 1.354 1.354 0 0 0-.436-.459 1.165 1.165 0 0 0-.642-.173 1.136 1.136 0 0 0-.69.223 1.33 1.33 0 0 0-.264.266V1H5.09v6.224h.918v-.281c.123.152.287.266.472.328.098.032.208.047.33.047.255 0 .483-.06.677-.177.192-.115.355-.278.486-.486a2.29 2.29 0 0 0 .293-.718 3.87 3.87 0 0 0 .096-.886 3.714 3.714 0 0 0-.078-.773zm-.86.758c0 .232-.02.439-.06.613-.036.172-.09.315-.159.424a.639.639 0 0 1-.233.237.582.582 0 0 1-.565.014.683.683 0 0 1-.21-.183.925.925 0 0 1-.142-.283A1.187 1.187 0 0 1 6 5.5v-.517c0-.164.02-.314.06-.447.036-.132.087-.242.156-.336a.668.668 0 0 1 .228-.208.584.584 0 0 1 .29-.071.554.554 0 0 1 .496.279c.063.099.108.214.143.354.031.143.05.306.05.482zM2.407 9.9a.913.913 0 0 1 .316-.239c.218-.1.547-.105.766-.018.104.042.204.1.32.184l.33.26V8.945l-.097-.062a1.932 1.932 0 0 0-.905-.215c-.308 0-.593.057-.846.168-.25.11-.467.27-.647.475-.18.21-.318.453-.403.717-.09.272-.137.57-.137.895 0 .289.043.561.13.808.086.249.211.471.373.652.161.185.361.333.597.441.232.104.493.155.778.155.233 0 .434-.028.613-.084.165-.05.322-.123.466-.217l.078-.061v-.889l-.2.095a.4.4 0 0 1-.076.026c-.05.017-.099.035-.128.049-.036.023-.227.09-.227.09-.06.024-.14.043-.218.059a.977.977 0 0 1-.599-.057.827.827 0 0 1-.306-.225 1.088 1.088 0 0 1-.205-.376 1.728 1.728 0 0 1-.076-.529c0-.21.028-.399.083-.56.054-.158.13-.294.22-.4zM14 6h-4V5h4.5l.5.5v6l-.5.5H7.879l2.07 2.071-.706.707-2.89-2.889v-.707l2.89-2.89L9.95 9l-2 2H14V6z"></path>
</svg>`,
"background-on": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.5" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.5" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
</svg>`,
"background-off": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.34" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" opacity="0.34" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.34" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
<path d="M2.5 13.5 13.5 2.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"></path>
</svg>`,
"theme-dark": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M10.794 3.647a.217.217 0 0 1 .412 0l.387 1.162c.173.518.58.923 1.097 1.096l1.162.388a.217.217 0 0 1 0 .412l-1.162.386a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.74 1.74 0 0 0 9.31 7.092l-1.162-.386a.217.217 0 0 1 0-.412l1.162-.388a1.73 1.73 0 0 0 1.097-1.096zM13.863.598a.144.144 0 0 1 .221-.071.14.14 0 0 1 .053.07l.258.775c.115.345.386.616.732.731l.774.258a.145.145 0 0 1 0 .274l-.774.259a1.16 1.16 0 0 0-.732.732l-.258.773a.145.145 0 0 1-.274 0l-.258-.773a1.16 1.16 0 0 0-.732-.732l-.774-.259a.145.145 0 0 1 0-.273l.774-.259c.346-.115.617-.386.732-.732z"></path>
<path fill="currentColor" d="M6.25 1.742a.67.67 0 0 1 .07.75 6.3 6.3 0 0 0-.768 3.028c0 2.746 1.746 5.084 4.193 5.979H1.774A7.2 7.2 0 0 1 1 8.245c0-3.013 1.85-5.598 4.484-6.694a.66.66 0 0 1 .766.19M.75 12.499a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
</svg>`,
"theme-light": `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M8.21 2.109a.256.256 0 0 0-.42 0L6.534 3.893a.256.256 0 0 1-.316.085l-1.982-.917a.256.256 0 0 0-.362.21l-.196 2.174a.256.256 0 0 1-.232.232l-2.175.196a.256.256 0 0 0-.209.362l.917 1.982a.256.256 0 0 1-.085.316L.11 9.791a.256.256 0 0 0 0 .418L1.23 11H3.1a5 5 0 1 1 9.8 0h1.869l1.123-.79a.256.256 0 0 0 0-.42l-1.785-1.257a.256.256 0 0 1-.085-.316l.917-1.982a.256.256 0 0 0-.21-.362l-2.174-.196a.256.256 0 0 1-.232-.232l-.196-2.175a.256.256 0 0 0-.362-.209l-1.982.917a.256.256 0 0 1-.316-.085z"></path>
<path fill="currentColor" d="M4 10q.001.519.126 1h7.748A4 4 0 1 0 4 10M.75 12a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
</svg>`,
};
function createToolbarButton(params: {
title: string;
active: boolean;
icon: ToolbarIconName;
onClick: () => void;
}): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "oc-diff-toolbar-button";
button.dataset.active = String(params.active);
button.title = params.title;
button.setAttribute("aria-label", params.title);
button.innerHTML = toolbarIconSvg[params.icon];
applyToolbarButtonStyles(button, params.active);
button.addEventListener("click", (event) => {
event.preventDefault();
params.onClick();
});
return button;
}
function applyToolbarStyles(toolbar: HTMLElement): void {
toolbar.style.display = "inline-flex";
toolbar.style.alignItems = "center";
toolbar.style.gap = "6px";
toolbar.style.marginInlineStart = "6px";
toolbar.style.flex = "0 0 auto";
}
function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): void {
button.style.display = "inline-flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
button.style.width = "24px";
button.style.height = "24px";
button.style.padding = "0";
button.style.margin = "0";
button.style.border = "0";
button.style.borderRadius = "0";
button.style.background = "transparent";
button.style.boxShadow = "none";
button.style.lineHeight = "0";
button.style.cursor = "pointer";
button.style.overflow = "visible";
button.style.flex = "0 0 auto";
button.style.opacity = active ? "0.92" : "0.6";
button.style.color =
viewerState.theme === "dark" ? "rgba(226, 232, 240, 0.74)" : "rgba(15, 23, 42, 0.52)";
button.dataset.active = String(active);
const icon = button.querySelector("svg");
if (!icon) {
return;
}
icon.style.display = "block";
icon.style.width = "16px";
icon.style.height = "16px";
icon.style.minWidth = "16px";
icon.style.minHeight = "16px";
icon.style.overflow = "visible";
icon.style.flex = "0 0 auto";
icon.style.color = "inherit";
icon.style.fill = "currentColor";
icon.style.pointerEvents = "none";
}
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
applyToolbarStyles(toolbar);
toolbar.append(
createToolbarButton({
title: viewerState.layout === "unified" ? "Switch to split diff" : "Switch to unified diff",
active: viewerState.layout === "split",
icon: viewerState.layout === "split" ? "split" : "unified",
onClick: () => {
viewerState.layout = viewerState.layout === "unified" ? "split" : "unified";
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.wrapEnabled ? "Disable word wrap" : "Enable word wrap",
active: viewerState.wrapEnabled,
icon: viewerState.wrapEnabled ? "wrap-on" : "wrap-off",
onClick: () => {
viewerState.wrapEnabled = !viewerState.wrapEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.backgroundEnabled
? "Hide background highlights"
: "Show background highlights",
active: viewerState.backgroundEnabled,
icon: viewerState.backgroundEnabled ? "background-on" : "background-off",
onClick: () => {
viewerState.backgroundEnabled = !viewerState.backgroundEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.theme === "dark" ? "Switch to light theme" : "Switch to dark theme",
active: viewerState.theme === "dark",
icon: viewerState.theme === "dark" ? "theme-dark" : "theme-light",
onClick: () => {
viewerState.theme = viewerState.theme === "dark" ? "light" : "dark";
syncAllControllers();
},
}),
);
return toolbar;
}
function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefined> {
return {
theme: payload.options.theme,
themeType: viewerState.theme,
diffStyle: viewerState.layout,
diffIndicators: payload.options.diffIndicators,
expandUnchanged: payload.options.expandUnchanged,
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
disableLineNumbers: payload.options.disableLineNumbers,
disableBackground: !viewerState.backgroundEnabled,
unsafeCSS: payload.options.unsafeCSS,
renderHeaderMetadata: () => createToolbar(),
};
}
function syncDocumentTheme(): void {
document.body.dataset.theme = viewerState.theme;
}
function applyState(controller: DiffController): void {
controller.diff.setOptions(createRenderOptions(controller.payload));
controller.diff.rerender();
}
function syncAllControllers(): void {
syncDocumentTheme();
for (const controller of controllers) {
applyState(controller);
}
}
export async function hydrateViewer(): Promise<void> {
// Rehydration replaces the current DOM card set; do not retain controllers
// from a previous render because they can keep stale DOM references alive.
controllers.length = 0;
const cards = await Promise.all(
getCards().map(async ({ host, payload }) => ({
host,
payload: await normalizeDiffViewerPayloadLanguages(payload),
})),
);
const langs = new Set<SupportedLanguages>();
const firstPayload = cards[0]?.payload;
if (firstPayload) {
viewerState.theme = firstPayload.options.themeType;
viewerState.layout = firstPayload.options.diffStyle;
viewerState.backgroundEnabled = firstPayload.options.backgroundEnabled;
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
}
for (const { payload } of cards) {
for (const lang of payload.langs) {
langs.add(lang);
}
}
await preloadHighlighter({
themes: ["pierre-light", "pierre-dark"],
langs: [...langs],
});
syncDocumentTheme();
for (const { host, payload } of cards) {
try {
ensureShadowRoot(host);
const diff = new FileDiff(createRenderOptions(payload));
diff.hydrate({
fileContainer: host,
prerenderedHTML: payload.prerenderedHTML,
...getHydrateProps(payload),
});
const controller = { payload, diff };
applyState(controller);
controllers.push(controller);
} catch (error) {
console.warn("Skipping diff card that failed to hydrate", error);
}
}
}
async function main(): Promise<void> {
try {
await hydrateViewer();
document.documentElement.dataset.openclawDiffsReady = "true";
} catch (error) {
document.documentElement.dataset.openclawDiffsError = "true";
console.error("Failed to hydrate diff viewer", error);
}
}
export const disableAutoStartKey = Symbol.for("openclaw.diffs.disableAutoStart");
const autoStartDisabled = Boolean(
(globalThis as typeof globalThis & Record<symbol, unknown>)[disableAutoStartKey],
);
if (typeof document !== "undefined" && !autoStartDisabled) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
void main();
});
} else {
void main();
}
}

View File

@@ -0,0 +1,95 @@
// Diffs plugin module implements viewer payload behavior.
import { DIFF_INDICATORS, DIFF_LAYOUTS, DIFF_THEMES } from "./types.js";
import type { DiffViewerPayload } from "./types.js";
const OVERFLOW_VALUES = ["scroll", "wrap"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function parseViewerPayloadJson(raw: string): DiffViewerPayload {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Diff payload is not valid JSON.");
}
if (!isDiffViewerPayload(parsed)) {
throw new Error("Diff payload has invalid shape.");
}
return parsed;
}
function isDiffViewerPayload(value: unknown): value is DiffViewerPayload {
if (!isRecord(value)) {
return false;
}
if (typeof value.prerenderedHTML !== "string") {
return false;
}
if (!Array.isArray(value.langs) || !value.langs.every((lang) => typeof lang === "string")) {
return false;
}
if (!isViewerOptions(value.options)) {
return false;
}
const hasFileDiff = isRecord(value.fileDiff);
const hasBeforeAfterFiles = isRecord(value.oldFile) && isRecord(value.newFile);
if (!hasFileDiff && !hasBeforeAfterFiles) {
return false;
}
return true;
}
function isViewerOptions(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (!isRecord(value.theme)) {
return false;
}
if (value.theme.light !== "pierre-light" || value.theme.dark !== "pierre-dark") {
return false;
}
if (!includesValue(DIFF_LAYOUTS, value.diffStyle)) {
return false;
}
if (!includesValue(DIFF_INDICATORS, value.diffIndicators)) {
return false;
}
if (!includesValue(DIFF_THEMES, value.themeType)) {
return false;
}
if (!includesValue(OVERFLOW_VALUES, value.overflow)) {
return false;
}
if (typeof value.disableLineNumbers !== "boolean") {
return false;
}
if (typeof value.expandUnchanged !== "boolean") {
return false;
}
if (typeof value.backgroundEnabled !== "boolean") {
return false;
}
if (typeof value.unsafeCSS !== "string") {
return false;
}
return true;
}
function includesValue<T extends readonly string[]>(values: T, value: unknown): value is T[number] {
return typeof value === "string" && values.includes(value as T[number]);
}