mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(diffs): harden viewer security and docs
This commit is contained in:
@@ -1,24 +1,34 @@
|
||||
---
|
||||
title: "Diffs"
|
||||
summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)"
|
||||
description: "Use the optional Diffs plugin to render before/after text or unified patches as a gateway-hosted diff view or a PNG."
|
||||
description: "Use the optional Diffs plugin to render before or after text or unified patches as a gateway-hosted diff view, a PNG image, or both."
|
||||
read_when:
|
||||
- You want agents to show code or markdown edits as diffs
|
||||
- You want a canvas-ready viewer URL or a rendered diff PNG
|
||||
- You need controlled, temporary diff artifacts with secure defaults
|
||||
---
|
||||
|
||||
# Diffs
|
||||
|
||||
`diffs` is an **optional plugin tool** that renders a read-only diff from either:
|
||||
`diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
|
||||
|
||||
- arbitrary `before` / `after` text
|
||||
- a unified patch
|
||||
It accepts either:
|
||||
|
||||
The tool can produce:
|
||||
- `before` and `after` text
|
||||
- a unified `patch`
|
||||
|
||||
- a gateway-hosted viewer URL for canvas use
|
||||
- a PNG image for message delivery
|
||||
- both outputs together
|
||||
It can return:
|
||||
|
||||
- a gateway viewer URL for canvas presentation
|
||||
- a rendered PNG path for message delivery
|
||||
- both outputs in one call
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Enable the plugin.
|
||||
2. Call `diffs` with `mode: "view"` for canvas-first flows.
|
||||
3. Call `diffs` with `mode: "image"` for chat/image-first flows.
|
||||
4. Call `diffs` with `mode: "both"` when you need both artifacts.
|
||||
|
||||
## Enable the plugin
|
||||
|
||||
@@ -34,20 +44,18 @@ The tool can produce:
|
||||
}
|
||||
```
|
||||
|
||||
## What agents get back
|
||||
## Typical agent workflow
|
||||
|
||||
- `mode: "view"` returns `details.viewerUrl` and `details.viewerPath`
|
||||
- `mode: "image"` returns `details.imagePath` only
|
||||
- `mode: "both"` returns the viewer details plus `details.imagePath`
|
||||
1. Agent calls `diffs`.
|
||||
2. Agent reads `details` fields.
|
||||
3. Agent either:
|
||||
- opens `details.viewerUrl` with `canvas present`
|
||||
- sends `details.imagePath` with `message` using `path` or `filePath`
|
||||
- does both
|
||||
|
||||
Typical agent patterns:
|
||||
## Input examples
|
||||
|
||||
- open `details.viewerUrl` in canvas with `canvas present`
|
||||
- send `details.imagePath` with the `message` tool using `path` or `filePath`
|
||||
|
||||
## Tool inputs
|
||||
|
||||
Before/after input:
|
||||
Before and after:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -58,7 +66,7 @@ Before/after input:
|
||||
}
|
||||
```
|
||||
|
||||
Patch input:
|
||||
Patch:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -67,16 +75,59 @@ Patch input:
|
||||
}
|
||||
```
|
||||
|
||||
Useful options:
|
||||
## Tool input reference
|
||||
|
||||
- `mode`: `view`, `image`, or `both`
|
||||
- `layout`: `unified` or `split`
|
||||
- `theme`: `light` or `dark`
|
||||
- `expandUnchanged`: expand unchanged sections instead of collapsing them
|
||||
- `path`: display name for before/after input
|
||||
- `title`: explicit diff title
|
||||
- `ttlSeconds`: viewer artifact lifetime
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link
|
||||
All fields are optional unless noted:
|
||||
|
||||
- `before` (`string`): original text. Required with `after` when `patch` is omitted.
|
||||
- `after` (`string`): updated text. Required with `before` when `patch` is omitted.
|
||||
- `patch` (`string`): unified diff text. Mutually exclusive with `before` and `after`.
|
||||
- `path` (`string`): display filename for before and after mode.
|
||||
- `lang` (`string`): language override hint for before and after mode.
|
||||
- `title` (`string`): viewer title override.
|
||||
- `mode` (`"view" | "image" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
|
||||
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
|
||||
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
|
||||
- `expandUnchanged` (`boolean`): expand unchanged sections.
|
||||
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
|
||||
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
|
||||
|
||||
Validation and limits:
|
||||
|
||||
- `before` and `after` each max 512 KiB.
|
||||
- `patch` max 2 MiB.
|
||||
- `path` max 2048 bytes.
|
||||
- `lang` max 128 bytes.
|
||||
- `title` max 1024 bytes.
|
||||
- Patch complexity cap: max 128 files and 120000 total lines.
|
||||
- `patch` and `before` or `after` together are rejected.
|
||||
|
||||
## Output details contract
|
||||
|
||||
The tool returns structured metadata under `details`.
|
||||
|
||||
Shared fields for modes that create a viewer:
|
||||
|
||||
- `artifactId`
|
||||
- `viewerUrl`
|
||||
- `viewerPath`
|
||||
- `title`
|
||||
- `expiresAt`
|
||||
- `inputKind`
|
||||
- `fileCount`
|
||||
- `mode`
|
||||
|
||||
Image fields when PNG is rendered:
|
||||
|
||||
- `imagePath`
|
||||
- `path` (same value as `imagePath`, for message tool compatibility)
|
||||
- `imageBytes`
|
||||
|
||||
Mode behavior summary:
|
||||
|
||||
- `mode: "view"`: viewer fields only.
|
||||
- `mode: "image"`: image fields only, no viewer artifact.
|
||||
- `mode: "both"`: viewer fields plus image fields. If screenshot fails, viewer still returns with `imageError`.
|
||||
|
||||
## Plugin defaults
|
||||
|
||||
@@ -121,15 +172,149 @@ Supported defaults:
|
||||
- `theme`
|
||||
- `mode`
|
||||
|
||||
Explicit tool parameters override the plugin defaults.
|
||||
Explicit tool parameters override these defaults.
|
||||
|
||||
## Notes
|
||||
## Security config
|
||||
|
||||
- Viewer pages are hosted locally by the gateway under `/plugins/diffs/...`.
|
||||
- Viewer artifacts are ephemeral and stored locally.
|
||||
- `mode: "image"` uses a faster image-only render path and does not create a viewer URL.
|
||||
- PNG rendering requires a Chromium-compatible browser. If auto-detection is not enough, set `browser.executablePath`.
|
||||
- Diff rendering is powered by [Diffs](https://diffs.com).
|
||||
- `security.allowRemoteViewer` (`boolean`, default `false`)
|
||||
- `false`: non-loopback requests to viewer routes are denied.
|
||||
- `true`: remote viewers are allowed if tokenized path is valid.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
security: {
|
||||
allowRemoteViewer: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Artifact lifecycle and storage
|
||||
|
||||
- Artifacts are stored under the temp subfolder: `$TMPDIR/openclaw-diffs`.
|
||||
- Viewer artifact metadata contains:
|
||||
- random artifact ID (20 hex chars)
|
||||
- random token (48 hex chars)
|
||||
- `createdAt` and `expiresAt`
|
||||
- stored `viewer.html` path
|
||||
- Default viewer TTL is 30 minutes when not specified.
|
||||
- Maximum accepted viewer TTL is 6 hours.
|
||||
- Cleanup runs opportunistically after artifact creation.
|
||||
- Expired artifacts are deleted.
|
||||
- Fallback cleanup removes stale folders older than 24 hours when metadata is missing.
|
||||
|
||||
## Viewer URL and network behavior
|
||||
|
||||
Viewer route:
|
||||
|
||||
- `/plugins/diffs/view/{artifactId}/{token}`
|
||||
|
||||
Viewer assets:
|
||||
|
||||
- `/plugins/diffs/assets/viewer.js`
|
||||
- `/plugins/diffs/assets/viewer-runtime.js`
|
||||
|
||||
URL construction behavior:
|
||||
|
||||
- If `baseUrl` is provided, it is used after strict validation.
|
||||
- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
|
||||
- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
|
||||
|
||||
`baseUrl` rules:
|
||||
|
||||
- Must be `http://` or `https://`.
|
||||
- Query and hash are rejected.
|
||||
- Origin plus optional base path is allowed.
|
||||
|
||||
## Security model
|
||||
|
||||
Viewer hardening:
|
||||
|
||||
- Loopback-only by default.
|
||||
- Tokenized viewer paths with strict ID and token validation.
|
||||
- Viewer response CSP:
|
||||
- `default-src 'none'`
|
||||
- scripts and assets only from self
|
||||
- no outbound `connect-src`
|
||||
- Remote miss throttling when remote access is enabled:
|
||||
- 40 failures per 60 seconds
|
||||
- 60 second lockout (`429 Too Many Requests`)
|
||||
|
||||
Image rendering hardening:
|
||||
|
||||
- Screenshot browser request routing is deny-by-default.
|
||||
- Only local viewer assets from `http://127.0.0.1/plugins/diffs/assets/*` are allowed.
|
||||
- External network requests are blocked.
|
||||
|
||||
## Browser requirements for image mode
|
||||
|
||||
`mode: "image"` and `mode: "both"` need a Chromium-compatible browser.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `browser.executablePath` in OpenClaw config.
|
||||
2. Environment variables:
|
||||
- `OPENCLAW_BROWSER_EXECUTABLE_PATH`
|
||||
- `BROWSER_EXECUTABLE_PATH`
|
||||
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
|
||||
3. Platform command/path discovery fallback.
|
||||
|
||||
Common failure text:
|
||||
|
||||
- `Diff image rendering requires a Chromium-compatible browser...`
|
||||
|
||||
Fix by installing Chrome, Chromium, Edge, or Brave, or setting one of the executable path options above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Input validation errors:
|
||||
|
||||
- `Provide patch or both before and after text.`
|
||||
- Include both `before` and `after`, or provide `patch`.
|
||||
- `Provide either patch or before/after input, not both.`
|
||||
- Do not mix input modes.
|
||||
- `Invalid baseUrl: ...`
|
||||
- Use `http(s)` origin with optional path, no query/hash.
|
||||
- `{field} exceeds maximum size (...)`
|
||||
- Reduce payload size.
|
||||
- Large patch rejection
|
||||
- Reduce patch file count or total lines.
|
||||
|
||||
Viewer accessibility issues:
|
||||
|
||||
- Viewer URL resolves to `127.0.0.1` by default.
|
||||
- For remote access scenarios, either:
|
||||
- pass `baseUrl` per tool call, or
|
||||
- use `gateway.bind=custom` and `gateway.customBindHost`
|
||||
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
|
||||
|
||||
Artifact not found:
|
||||
|
||||
- Artifact expired due TTL.
|
||||
- Token or path changed.
|
||||
- Cleanup removed stale data.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
- Prefer `mode: "view"` for local interactive reviews in canvas.
|
||||
- Prefer `mode: "image"` for outbound chat channels that need an attachment.
|
||||
- Keep `allowRemoteViewer` disabled unless your deployment requires remote viewer URLs.
|
||||
- Set explicit short `ttlSeconds` for sensitive diffs.
|
||||
- Avoid sending secrets in diff input when not required.
|
||||
|
||||
Diff rendering engine:
|
||||
|
||||
- Powered by [Diffs](https://diffs.com).
|
||||
|
||||
## Related docs
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ Useful options:
|
||||
- `path`: display name for before/after input
|
||||
- `title`: explicit viewer title
|
||||
- `ttlSeconds`: artifact lifetime
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
|
||||
|
||||
Input safety limits:
|
||||
|
||||
- `before` / `after`: max 512 KiB each
|
||||
- `patch`: max 2 MiB
|
||||
- patch rendering cap: max 128 files / 120,000 lines
|
||||
|
||||
## Plugin Defaults
|
||||
|
||||
@@ -86,6 +92,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
|
||||
|
||||
Explicit tool parameters still win over these defaults.
|
||||
|
||||
Security options:
|
||||
|
||||
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
|
||||
|
||||
## Example Agent Prompts
|
||||
|
||||
Open in canvas:
|
||||
@@ -152,6 +162,8 @@ diff --git a/src/example.ts b/src/example.ts
|
||||
## Notes
|
||||
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the local temp directory.
|
||||
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
|
||||
- Remote viewer misses are throttled to reduce token-guess abuse.
|
||||
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
|
||||
- Diff rendering is powered by [Diffs](https://diffs.com).
|
||||
|
||||
@@ -110,10 +110,10 @@ describe("diffs plugin registration", () => {
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpHandler?.(
|
||||
{
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -127,3 +127,10 @@ describe("diffs plugin registration", () => {
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
});
|
||||
});
|
||||
|
||||
function localReq(input: { method: string; url: string }): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
import { diffsPluginConfigSchema, resolveDiffsPluginDefaults } from "./src/config.js";
|
||||
import {
|
||||
diffsPluginConfigSchema,
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./src/config.js";
|
||||
import { createDiffsHttpHandler } from "./src/http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./src/store.js";
|
||||
@@ -14,13 +18,20 @@ const plugin = {
|
||||
configSchema: diffsPluginConfigSchema,
|
||||
register(api: OpenClawPluginApi) {
|
||||
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
|
||||
const security = resolveDiffsPluginSecurity(api.pluginConfig);
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
logger: api.logger,
|
||||
});
|
||||
|
||||
api.registerTool(createDiffsTool({ api, store, defaults }));
|
||||
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
|
||||
api.registerHttpHandler(
|
||||
createDiffsHttpHandler({
|
||||
store,
|
||||
logger: api.logger,
|
||||
allowRemoteViewer: security.allowRemoteViewer,
|
||||
}),
|
||||
);
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
"defaults.mode": {
|
||||
"label": "Default Output Mode",
|
||||
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
|
||||
},
|
||||
"security.allowRemoteViewer": {
|
||||
"label": "Allow Remote Viewer",
|
||||
"help": "Allow non-loopback access to diff viewer URLs when the token path is known."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
@@ -101,6 +105,16 @@
|
||||
"default": "both"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allowRemoteViewer": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,28 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: params.theme,
|
||||
});
|
||||
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const asset = await getServedViewerAsset(pathname);
|
||||
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;
|
||||
}
|
||||
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
const asset = await getServedViewerAsset(parsed.pathname);
|
||||
if (!asset) {
|
||||
await route.abort();
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffsPluginDefaults } from "./config.js";
|
||||
import {
|
||||
DEFAULT_DIFFS_PLUGIN_SECURITY,
|
||||
DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./config.js";
|
||||
|
||||
describe("resolveDiffsPluginDefaults", () => {
|
||||
it("returns built-in defaults when config is missing", () => {
|
||||
@@ -70,3 +75,15 @@ describe("resolveDiffsPluginDefaults", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ type DiffsPluginConfig = {
|
||||
theme?: DiffTheme;
|
||||
mode?: DiffMode;
|
||||
};
|
||||
security?: {
|
||||
allowRemoteViewer?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
|
||||
@@ -40,6 +43,14 @@ export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
|
||||
mode: "both",
|
||||
};
|
||||
|
||||
export type DiffsPluginSecurityConfig = {
|
||||
allowRemoteViewer: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
|
||||
allowRemoteViewer: false,
|
||||
};
|
||||
|
||||
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -89,6 +100,16 @@ const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
allowRemoteViewer: {
|
||||
type: "boolean",
|
||||
default: DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -135,6 +156,21 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
|
||||
};
|
||||
}
|
||||
|
||||
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 toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
|
||||
const {
|
||||
fontFamily,
|
||||
|
||||
@@ -31,10 +31,10 @@ describe("createDiffsHttpHandler", () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -55,10 +55,10 @@ describe("createDiffsHttpHandler", () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -70,10 +70,10 @@ describe("createDiffsHttpHandler", () => {
|
||||
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",
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -85,10 +85,10 @@ describe("createDiffsHttpHandler", () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -101,10 +101,10 @@ describe("createDiffsHttpHandler", () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
} as IncomingMessage,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -112,4 +112,89 @@ describe("createDiffsHttpHandler", () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("blocks non-loopback viewer access by default", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("allows remote access when allowRemoteViewer is enabled", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(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);
|
||||
});
|
||||
});
|
||||
|
||||
function localReq(input: { method: string; url: string }): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: { method: string; url: string }): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.j
|
||||
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'",
|
||||
@@ -20,7 +24,10 @@ const VIEWER_CONTENT_SECURITY_POLICY = [
|
||||
export function createDiffsHttpHandler(params: {
|
||||
store: DiffArtifactStore;
|
||||
logger?: PluginLogger;
|
||||
allowRemoteViewer?: boolean;
|
||||
}) {
|
||||
const viewerFailureLimiter = new ViewerFailureLimiter();
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const parsed = parseRequestUrl(req.url);
|
||||
if (!parsed) {
|
||||
@@ -35,11 +42,29 @@ export function createDiffsHttpHandler(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress);
|
||||
const localRequest = isLoopbackClientIp(remoteKey);
|
||||
if (!localRequest && params.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 (!localRequest) {
|
||||
const throttled = viewerFailureLimiter.check(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];
|
||||
@@ -49,18 +74,27 @@ export function createDiffsHttpHandler(params: {
|
||||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
|
||||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
|
||||
) {
|
||||
if (!localRequest) {
|
||||
viewerFailureLimiter.recordFailure(remoteKey);
|
||||
}
|
||||
respondText(res, 404, "Diff not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const artifact = await params.store.getArtifact(id, token);
|
||||
if (!artifact) {
|
||||
if (!localRequest) {
|
||||
viewerFailureLimiter.recordFailure(remoteKey);
|
||||
}
|
||||
respondText(res, 404, "Diff not found or expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await params.store.readHtml(id);
|
||||
if (!localRequest) {
|
||||
viewerFailureLimiter.reset(remoteKey);
|
||||
}
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, "text/html; charset=utf-8");
|
||||
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
|
||||
@@ -71,6 +105,9 @@ export function createDiffsHttpHandler(params: {
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!localRequest) {
|
||||
viewerFailureLimiter.recordFailure(remoteKey);
|
||||
}
|
||||
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
|
||||
respondText(res, 500, "Failed to load diff");
|
||||
return true;
|
||||
@@ -134,3 +171,90 @@ function setSharedHeaders(res: ServerResponse, contentType: string): void {
|
||||
res.setHeader("x-content-type-options", "nosniff");
|
||||
res.setHeader("referrer-policy", "no-referrer");
|
||||
}
|
||||
|
||||
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
|
||||
const normalized = remoteAddress?.trim().toLowerCase();
|
||||
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";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,30 @@ describe("renderDiffDocument", () => {
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
});
|
||||
|
||||
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,
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
|
||||
|
||||
const DEFAULT_FILE_NAME = "diff.txt";
|
||||
const MAX_PATCH_FILE_COUNT = 128;
|
||||
const MAX_PATCH_TOTAL_LINES = 120_000;
|
||||
|
||||
function escapeCssString(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
@@ -344,6 +346,17 @@ async function renderPatchDiff(
|
||||
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 viewerPayloadOptions = buildDiffOptions(options);
|
||||
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
|
||||
|
||||
@@ -62,6 +62,35 @@ describe("DiffArtifactStore", () => {
|
||||
expect(updated.imagePath).toBe(imagePath);
|
||||
});
|
||||
|
||||
it("rejects image 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.updateImagePath(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("allocates standalone image paths outside artifact metadata", async () => {
|
||||
const imagePath = store.allocateStandaloneImagePath();
|
||||
expect(imagePath).toMatch(/preview\.png$/);
|
||||
|
||||
@@ -26,7 +26,7 @@ export class DiffArtifactStore {
|
||||
private nextCleanupAt = 0;
|
||||
|
||||
constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) {
|
||||
this.rootDir = params.rootDir;
|
||||
this.rootDir = path.resolve(params.rootDir);
|
||||
this.logger = params.logger;
|
||||
this.cleanupIntervalMs =
|
||||
params.cleanupIntervalMs === undefined
|
||||
@@ -59,7 +59,7 @@ export class DiffArtifactStore {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await fs.writeFile(htmlPath, params.html, "utf8");
|
||||
await this.writeMeta(meta);
|
||||
this.maybeCleanupExpired();
|
||||
this.scheduleCleanup();
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ export class DiffArtifactStore {
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
return await fs.readFile(meta.htmlPath, "utf8");
|
||||
const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
|
||||
return await fs.readFile(htmlPath, "utf8");
|
||||
}
|
||||
|
||||
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
|
||||
@@ -91,9 +92,10 @@ export class DiffArtifactStore {
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
const normalizedImagePath = this.normalizeStoredPath(imagePath, "imagePath");
|
||||
const next: DiffArtifactMeta = {
|
||||
...meta,
|
||||
imagePath,
|
||||
imagePath: normalizedImagePath,
|
||||
};
|
||||
await this.writeMeta(next);
|
||||
return next;
|
||||
@@ -108,6 +110,10 @@ export class DiffArtifactStore {
|
||||
return path.join(this.artifactDir(id), "preview.png");
|
||||
}
|
||||
|
||||
scheduleCleanup(): void {
|
||||
this.maybeCleanupExpired();
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
await this.ensureRoot();
|
||||
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
|
||||
@@ -164,7 +170,7 @@ export class DiffArtifactStore {
|
||||
}
|
||||
|
||||
private artifactDir(id: string): string {
|
||||
return path.join(this.rootDir, id);
|
||||
return this.resolveWithinRoot(id);
|
||||
}
|
||||
|
||||
private metaPath(id: string): string {
|
||||
@@ -191,6 +197,31 @@ export class DiffArtifactStore {
|
||||
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 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 {
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("diffs tool", () => {
|
||||
});
|
||||
|
||||
it("returns an image artifact in image mode", async () => {
|
||||
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
|
||||
const screenshotter = {
|
||||
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
|
||||
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
|
||||
@@ -68,6 +69,7 @@ describe("diffs tool", () => {
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to view output when both mode cannot render an image", async () => {
|
||||
@@ -110,6 +112,38 @@ describe("diffs tool", () => {
|
||||
).rejects.toThrow("Invalid baseUrl");
|
||||
});
|
||||
|
||||
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-large-before", {
|
||||
before: large,
|
||||
after: "ok",
|
||||
mode: "view",
|
||||
}),
|
||||
).rejects.toThrow("before exceeds maximum size");
|
||||
});
|
||||
|
||||
it("rejects oversized patch payloads", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute?.("tool-large-patch", {
|
||||
patch: "x".repeat(2_100_000),
|
||||
mode: "view",
|
||||
}),
|
||||
).rejects.toThrow("patch exceeds maximum size");
|
||||
});
|
||||
|
||||
it("uses configured defaults when tool params omit them", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
} 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;
|
||||
|
||||
function stringEnum<T extends readonly string[]>(values: T, description: string) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
@@ -28,12 +34,30 @@ 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." })),
|
||||
path: Type.Optional(Type.String({ description: "Display path for before/after input." })),
|
||||
lang: Type.Optional(
|
||||
Type.String({ description: "Optional language override for before/after input." }),
|
||||
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,
|
||||
}),
|
||||
),
|
||||
title: Type.Optional(Type.String({ description: "Optional title for the rendered diff." })),
|
||||
mode: Type.Optional(
|
||||
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
|
||||
),
|
||||
@@ -102,6 +126,7 @@ export function createDiffsTool(params: {
|
||||
theme,
|
||||
});
|
||||
const imageStats = await fs.stat(imagePath);
|
||||
params.store.scheduleCleanup();
|
||||
|
||||
return {
|
||||
content: [
|
||||
@@ -217,27 +242,46 @@ function normalizeDiffInput(params: DiffsToolParams): DiffInput {
|
||||
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: params.title?.trim() || undefined,
|
||||
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 = params.path?.trim() || undefined;
|
||||
const lang = params.lang?.trim() || undefined;
|
||||
const title = params.title?.trim() || undefined;
|
||||
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: params.path?.trim() || undefined,
|
||||
lang: params.lang?.trim() || undefined,
|
||||
title: params.title?.trim() || undefined,
|
||||
path,
|
||||
lang,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,3 +322,10 @@ class PluginToolInputError extends Error {
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
|
||||
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).`);
|
||||
}
|
||||
|
||||
55
extensions/diffs/src/url.test.ts
Normal file
55
extensions/diffs/src/url.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
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("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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
@@ -10,10 +9,15 @@ export function buildViewerUrl(params: {
|
||||
}): string {
|
||||
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
|
||||
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
|
||||
const normalizedPath = params.viewerPath.startsWith("/")
|
||||
const viewerPath = params.viewerPath.startsWith("/")
|
||||
? params.viewerPath
|
||||
: `/${params.viewerPath}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
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): string {
|
||||
@@ -26,6 +30,12 @@ export function normalizeViewerBaseUrl(raw: string): string {
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`baseUrl must use http or https: ${raw}`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`baseUrl must not include query/hash: ${raw}`);
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash;
|
||||
}
|
||||
@@ -34,87 +44,13 @@ 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 bind = config.gateway?.bind ?? "loopback";
|
||||
const customHost = config.gateway?.customBindHost?.trim();
|
||||
|
||||
if (bind === "custom" && config.gateway?.customBindHost?.trim()) {
|
||||
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`;
|
||||
}
|
||||
|
||||
if (bind === "lan") {
|
||||
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
|
||||
}
|
||||
|
||||
if (bind === "tailnet") {
|
||||
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
function pickPrimaryLanIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
const preferredNames = ["en0", "eth0"];
|
||||
|
||||
for (const name of preferredNames) {
|
||||
const candidate = pickPrivateAddress(nets[name]);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entries of Object.values(nets)) {
|
||||
const candidate = pickPrivateAddress(entries);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickPrimaryTailnetIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const entries of Object.values(nets)) {
|
||||
const candidate = entries?.find((entry) => isTailnetIPv4(entry.address) && !entry.internal);
|
||||
if (candidate?.address) {
|
||||
return candidate.address;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickPrivateAddress(entries: os.NetworkInterfaceInfo[] | undefined): string | undefined {
|
||||
return entries?.find(
|
||||
(entry) => entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address),
|
||||
)?.address;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
const octets = parseIpv4(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
}
|
||||
|
||||
function isTailnetIPv4(address: string): boolean {
|
||||
const octets = parseIpv4(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 100 && b >= 64 && b <= 127;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): number[] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
const octets = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return octets;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user