fix: share plugin runtime helpers

Consolidate shared plugin runtime MIME/schema helpers, preserve canonical runtime behavior, and guard QQBot STT fetches.
This commit is contained in:
Peter Steinberger
2026-05-08 00:28:43 +01:00
committed by GitHub
parent f3c9203631
commit 6a4069dead
127 changed files with 789 additions and 829 deletions

View File

@@ -169,6 +169,7 @@ Docs: https://docs.openclaw.ai
- Skills: cap skills watcher directory traversal at the same depth used by skill discovery so large non-skill trees under configured skill roots do not exhaust file descriptors on startup. Fixes #75501. Thanks @wzq-xzwj.
- Docs/Docker: document a local Compose override for Docker Desktop DNS failures in the shared-network `openclaw-cli` sidecar, keeping the default compose setup hardened while unblocking `openclaw plugins install` when users opt in. Fixes #79018. Thanks @Jason-Vaughan.
- Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762.
- Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews.
- Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987.
- fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987.
- Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987.

View File

@@ -1,2 +1,2 @@
28e280d21693216c99cfa8da553589b41741d37c0ada956e316ee01d3d6c202c plugin-sdk-api-baseline.json
633dae33da97f6a073c5561709c57d5c0b7ff67af0512d0261f05455c24b38de plugin-sdk-api-baseline.jsonl
973ce3342740726100f6f09d18c6802474f5f7eab255253cf6ea3e8d66c9a383 plugin-sdk-api-baseline.json
f4cbbaaa129733216e1a566865d86b0832f5f35bc3db6c9ead1e2f937564dc68 plugin-sdk-api-baseline.jsonl

View File

@@ -46,6 +46,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| --- | --- |
| `plugin-sdk/channel-core` | `defineChannelPluginEntry`, `defineSetupPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase` |
| `plugin-sdk/config-schema` | Root `openclaw.json` Zod schema export (`OpenClawSchema`) |
| `plugin-sdk/json-schema-runtime` | Cached JSON Schema validation helper for plugin-owned schemas |
| `plugin-sdk/channel-setup` | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` |
| `plugin-sdk/setup` | Shared setup wizard helpers, allowlist prompts, setup status builders |
| `plugin-sdk/setup-runtime` | `createPatchedAccountSetupAdapter`, `createEnvPatchedAccountSetupAdapter`, `createSetupInputPresenceValidator`, `noteChannelLookupFailure`, `noteChannelLookupSummary`, `promptResolvedAllowFrom`, `splitSetupEntries`, `createAllowlistSetupWizardProxy`, `createDelegatedSetupWizardProxy` |
@@ -264,6 +265,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio helper exports |

View File

@@ -38,9 +38,32 @@ describe("browser url pattern matching", () => {
});
it("matches glob patterns", () => {
expect(matchBrowserUrlPattern("*", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("**/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://other.com/a")).toBe(false);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/app/dash")).toBe(
false,
);
expect(matchBrowserUrlPattern("https://example.com/**", "https://example.com/app/dash")).toBe(
true,
);
});
it("treats URL punctuation as literal in wildcard patterns", () => {
expect(
matchBrowserUrlPattern(
"https://example.com/download?file=*",
"https://example.com/download?file=report.pdf",
),
).toBe(true);
expect(
matchBrowserUrlPattern(
"https://example.com/download?file=*",
"https://example.com/downloadXfile=report.pdf",
),
).toBe(false);
expect(matchBrowserUrlPattern("http://[::1]:*/**", "http://[::1]:9222/json/list")).toBe(true);
});
it("rejects empty patterns", () => {

View File

@@ -1,3 +1,22 @@
function wildcardPatternToRegExp(pattern: string): RegExp {
let source = "^";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index] ?? "";
if (char === "*") {
if (pattern[index + 1] === "*") {
source += ".*";
index += 1;
} else {
source += "[^/]*";
}
continue;
}
source += char.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&");
}
source += "$";
return new RegExp(source, "u");
}
export function matchBrowserUrlPattern(pattern: string, url: string): boolean {
const trimmedPattern = pattern.trim();
if (!trimmedPattern) {
@@ -6,10 +25,11 @@ export function matchBrowserUrlPattern(pattern: string, url: string): boolean {
if (trimmedPattern === url) {
return true;
}
if (trimmedPattern === "*") {
return true;
}
if (trimmedPattern.includes("*")) {
const escaped = trimmedPattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
return regex.test(url);
return wildcardPatternToRegExp(trimmedPattern).test(url);
}
return url.includes(trimmedPattern);
}

View File

@@ -36,8 +36,8 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) {
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
}
@@ -63,6 +63,7 @@ describe("byteplus video generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual(
expect.objectContaining({
taskId: "task_123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -127,7 +128,7 @@ async function downloadBytePlusVideo(params: {
return {
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}

View File

@@ -12,7 +12,7 @@
"@lit/context": "^1.1.6",
"chokidar": "^5.0.0",
"lit": "^3.3.2",
"typebox": "^1.0.58",
"typebox": "1.1.37",
"ws": "^8.20.0"
},
"openclaw": {

View File

@@ -11,8 +11,7 @@
"@mariozechner/pi-coding-agent": "0.73.0",
"@openai/codex": "0.128.0",
"ajv": "^8.20.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
"ws": "^8.20.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -1,5 +1,5 @@
import { createHmac, randomBytes } from "node:crypto";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
const START_OPTIONS_KEY_SECRET = randomBytes(32);

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
isProviderApiKeyConfigured,
type AuthProfileStore,
@@ -304,25 +305,10 @@ async function readJsonResponse<T>(params: {
}
}
function inferFileExtension(params: { fileName?: string; mimeType?: string }): string {
const normalizedMime = normalizeOptionalLowercaseString(params.mimeType);
if (normalizedMime?.includes("jpeg")) {
return "jpg";
}
if (normalizedMime?.includes("png")) {
return "png";
}
if (normalizedMime?.includes("webm")) {
return "webm";
}
if (normalizedMime?.includes("mp4")) {
return "mp4";
}
if (normalizedMime?.includes("mpeg")) {
return "mp3";
}
if (normalizedMime?.includes("wav")) {
return "wav";
function resolveFileExtension(params: { fileName?: string; mimeType?: string }): string {
const extension = extensionForMime(params.mimeType);
if (extension) {
return extension.slice(1);
}
const fileName = params.fileName?.trim();
if (!fileName) {
@@ -356,7 +342,7 @@ async function uploadInputImage(params: {
"image",
new Blob([toBlobBytes(params.image.buffer)], { type: params.image.mimeType }),
normalizeOptionalString(params.image.fileName) ||
`input.${inferFileExtension({ mimeType: params.image.mimeType })}`,
`input.${resolveFileExtension({ mimeType: params.image.mimeType })}`,
);
form.set("type", "input");
form.set("overwrite", "true");
@@ -823,7 +809,7 @@ export async function runComfyWorkflow(params: {
mimeType: downloaded.mimeType,
fileName:
originalName ||
`${params.capability}-${assetIndex}.${inferFileExtension({ mimeType: downloaded.mimeType })}`,
`${params.capability}-${assetIndex}.${resolveFileExtension({ mimeType: downloaded.mimeType })}`,
nodeId: output.nodeId,
});
}

View File

@@ -83,4 +83,31 @@ describe("deepinfra video generation provider", () => {
});
expect(release).toHaveBeenCalledOnce();
});
it("names base64 WebM data URL outputs from the MIME type", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
video_url: `data:video/webm;base64,${Buffer.from("webm-data").toString("base64")}`,
request_id: "req_webm",
inference_status: { status: "succeeded" },
}),
},
release: vi.fn(async () => {}),
});
const provider = buildDeepInfraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "deepinfra",
model: "deepinfra/Pixverse/Pixverse-T2V",
prompt: "A WebM data URL",
cfg: {},
});
expect(result.videos[0]).toMatchObject({
mimeType: "video/webm",
fileName: "video-1.webm",
});
expect(result.videos[0]?.buffer).toEqual(Buffer.from("webm-data"));
});
});

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -65,7 +66,7 @@ function parseVideoDataUrl(url: string): GeneratedVideoAsset | undefined {
return undefined;
}
const mimeType = match[1] ?? "video/mp4";
const ext = mimeType.includes("webm") ? "webm" : "mp4";
const ext = extensionForMime(mimeType)?.slice(1) ?? "mp4";
return {
buffer: Buffer.from(match[2] ?? "", "base64"),
mimeType,

View File

@@ -1,18 +1,11 @@
import { resolveLanguage } from "@pierre/diffs";
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { DiffViewerPayload } from "./types.js";
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
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,
): Promise<SupportedLanguages | undefined> {

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Static, Type } from "typebox";
@@ -34,19 +35,6 @@ 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,
options: { deprecated?: boolean } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
description,
...options,
});
}
const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
@@ -76,17 +64,23 @@ const DiffsToolSchema = Type.Object(
}),
),
mode: Type.Optional(
stringEnum(
DIFF_MODES,
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
),
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." }),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print."),
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." }),
),
fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
fileScale: Type.Optional(
Type.Number({
description: "Optional rendered-file device scale factor override (1-4).",
@@ -103,13 +97,15 @@ const DiffsToolSchema = Type.Object(
),
/** @deprecated Use fileQuality. */
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality.", {
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "Deprecated alias for fileQuality.",
deprecated: true,
}),
),
/** @deprecated Use fileFormat. */
imageFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.", {
stringEnum(DIFF_OUTPUT_FORMATS, {
description: "Deprecated alias for fileFormat.",
deprecated: true,
}),
),

View File

@@ -1,12 +1,8 @@
import { stripInlineDirectiveTagsForDisplay } from "openclaw/plugin-sdk/text-runtime";
import { escapeRegExp, stripInlineDirectiveTagsForDisplay } from "openclaw/plugin-sdk/text-runtime";
const SPEECH_EMOJI_RE =
/(?:\p{Extended_Pictographic}(?:\uFE0F|\u200D|\p{Extended_Pictographic}|\p{Emoji_Modifier})*)+/gu;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripEmojiForSpeech(text: string): string {
return text
.replace(SPEECH_EMOJI_RE, " ")

View File

@@ -58,6 +58,7 @@ describe("fal video generation provider", () => {
responseUrl: string;
videoUrl: string;
bytes: string;
contentType?: string;
responseExtras?: Record<string, unknown>;
}) {
fetchGuardMock
@@ -78,7 +79,9 @@ describe("fal video generation provider", () => {
},
}),
)
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: params.bytes }));
.mockResolvedValueOnce(
releasedVideo({ contentType: params.contentType ?? "video/mp4", bytes: params.bytes }),
);
}
function getSubmitBody(): Record<string, unknown> {
@@ -119,7 +122,8 @@ describe("fal video generation provider", () => {
statusUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status",
responseUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123",
videoUrl: "https://fal.run/files/video.mp4",
bytes: "mp4-bytes",
bytes: "webm-bytes",
contentType: "video/webm",
});
const provider = buildFalVideoGenerationProvider();
@@ -158,7 +162,8 @@ describe("fal video generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.videos[0]?.mimeType).toBe("video/webm");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.videos[0]?.url).toBe("https://fal.run/files/video.mp4");
expect(result.metadata).toEqual({
requestId: "req-123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -121,7 +122,7 @@ async function downloadFalVideo(
url,
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
} finally {
await release();

View File

@@ -4,6 +4,7 @@ import { isAbsolute, resolve } from "node:path";
import { basename } from "node:path";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import type { OpenClawPluginApi } from "../runtime-api.js";
@@ -577,7 +578,7 @@ async function resolveUploadInput(
);
}
const mimeMatch = header.match(/data:([^;]+)/);
const ext = mimeMatch?.[1]?.split("/")[1] ?? "png";
const ext = extensionForMime(mimeMatch?.[1])?.slice(1) ?? "png";
// Estimate decoded byte count from base64 length BEFORE allocating the
// full buffer to avoid spiking memory on oversized payloads.
const estimatedBytes = Math.ceil((trimmedData.length * 3) / 4);

View File

@@ -137,6 +137,49 @@ describe("handleFileFetch — happy path", () => {
// Accept either.
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
});
it("detects extensionless plain text as text/plain", async () => {
const target = path.join(tmpRoot, "LICENSE");
const contents = "Permission is hereby granted\n";
await fs.writeFile(target, contents);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("text/plain");
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
});
it("does not classify extensionless binary content as text/plain", async () => {
const target = path.join(tmpRoot, "opaque");
await fs.writeFile(target, Buffer.from([0x00, 0x01, 0x02, 0xff]));
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("application/octet-stream");
});
it("sniffs binary content instead of trusting a misleading extension", async () => {
const target = path.join(tmpRoot, "image.txt");
await fs.writeFile(
target,
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52,
]),
);
const r = await handleFileFetch({ path: target });
if (!r.ok) {
throw new Error("expected ok");
}
expect(r.mimeType).toBe("image/png");
});
});
describe("handleFileFetch — size enforcement", () => {

View File

@@ -1,15 +1,15 @@
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import path from "node:path";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import {
FsSafeError,
resolveAbsolutePathForRead,
root,
} from "openclaw/plugin-sdk/security-runtime";
import { EXTENSION_MIME } from "../shared/mime.js";
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
const TEXT_SNIFF_MAX_BYTES = 8192;
type FileFetchParams = {
path?: unknown;
@@ -47,25 +47,6 @@ type FileFetchErr = {
type FileFetchResult = FileFetchOk | FileFetchErr;
function detectMimeType(filePath: string): string {
if (process.platform !== "win32") {
try {
const result = spawnSync("file", ["-b", "--mime-type", filePath], {
encoding: "utf-8",
timeout: 2000,
});
const stdout = result.stdout?.trim();
if (result.status === 0 && stdout) {
return stdout;
}
} catch {
// fall through to extension fallback
}
}
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
}
function clampMaxBytes(input: unknown): number {
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
return FILE_FETCH_DEFAULT_MAX_BYTES;
@@ -101,6 +82,39 @@ function classifyFsError(err: unknown): FileFetchErrCode {
return "READ_ERROR";
}
function isLikelyPlainText(buffer: Buffer): boolean {
if (buffer.byteLength === 0) {
return true;
}
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
if (sample.includes(0)) {
return false;
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(sample);
} catch {
return false;
}
let controlBytes = 0;
for (const byte of sample) {
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
controlBytes += 1;
}
}
return controlBytes / sample.byteLength < 0.01;
}
async function detectFetchedFileMime(params: {
buffer: Buffer;
filePath: string;
}): Promise<string> {
const detected = await detectMime(params);
if (detected) {
return detected;
}
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
}
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
const requestedPath = params.path;
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
@@ -196,7 +210,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const base64 = buffer.toString("base64");
const mimeType = detectMimeType(opened.realPath);
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
return {
ok: true,

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
EXTENSION_MIME,
IMAGE_MIME_INLINE_SET,
TEXT_INLINE_MAX_BYTES,
TEXT_INLINE_MIME_SET,
@@ -13,6 +12,8 @@ describe("mimeFromExtension", () => {
expect(mimeFromExtension("/abs/path/bar.JPG")).toBe("image/jpeg");
expect(mimeFromExtension("doc.pdf")).toBe("application/pdf");
expect(mimeFromExtension("notes.md")).toBe("text/markdown");
expect(mimeFromExtension("trace.log")).toBe("text/plain");
expect(mimeFromExtension("bitmap.bmp")).toBe("image/bmp");
});
it("falls back to application/octet-stream for unknown extensions", () => {
@@ -28,11 +29,11 @@ describe("mimeFromExtension", () => {
describe("MIME constants", () => {
it("EXTENSION_MIME includes the v1 image set", () => {
expect(EXTENSION_MIME[".png"]).toBe("image/png");
expect(EXTENSION_MIME[".jpg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".jpeg"]).toBe("image/jpeg");
expect(EXTENSION_MIME[".webp"]).toBe("image/webp");
expect(EXTENSION_MIME[".gif"]).toBe("image/gif");
expect(mimeFromExtension("image.png")).toBe("image/png");
expect(mimeFromExtension("image.jpg")).toBe("image/jpeg");
expect(mimeFromExtension("image.jpeg")).toBe("image/jpeg");
expect(mimeFromExtension("image.webp")).toBe("image/webp");
expect(mimeFromExtension("image.gif")).toBe("image/gif");
});
it("IMAGE_MIME_INLINE_SET is the inline-renderable image set", () => {
@@ -50,6 +51,7 @@ describe("MIME constants", () => {
expect(TEXT_INLINE_MIME_SET.has("text/markdown")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("application/json")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/csv")).toBe(true);
expect(TEXT_INLINE_MIME_SET.has("text/xml")).toBe(true);
});
it("TEXT_INLINE_MAX_BYTES is the documented 8KB cap", () => {

View File

@@ -1,28 +1,4 @@
import path from "node:path";
// Single source of truth for extension→MIME mapping. Used by all four
// handlers/tools so adding a new extension lands everywhere at once.
export const EXTENSION_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
".pdf": "application/pdf",
".txt": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".html": "text/html",
".xml": "application/xml",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
};
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
// MIME types we treat as inline-displayable images for vision-capable models.
// Note: heic/heif are detectable but not all providers can render them, so we
@@ -43,11 +19,11 @@ export const TEXT_INLINE_MIME_SET = new Set([
"text/html",
"application/json",
"application/xml",
"text/xml",
]);
export const TEXT_INLINE_MAX_BYTES = 8 * 1024;
export function mimeFromExtension(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return EXTENSION_MIME[ext] ?? "application/octet-stream";
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
}

View File

@@ -1,3 +1,4 @@
import { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import {
jsonResult,
@@ -7,19 +8,6 @@ import {
import { Type } from "typebox";
import { runFirecrawlScrape } from "./firecrawl-client.js";
function optionalStringEnum<const T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Optional(
Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
}),
);
}
const FirecrawlScrapeToolSchema = Type.Object(
{
url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }),

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
GoogleMeetConfig,
@@ -127,10 +128,6 @@ function resolveProbeTimeoutMs(input: number | undefined, fallback: number): num
return Math.min(Math.trunc(input), 120_000);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isManagedChromeBrowserSession(session: GoogleMeetSession): boolean {
return Boolean(
(session.transport === "chrome" || session.transport === "chrome-node") &&

View File

@@ -1,4 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "../config.js";
import {
asBrowserTabs,
@@ -71,10 +72,6 @@ export function isGoogleMeetBrowserManualActionError(
return error instanceof GoogleMeetBrowserManualActionError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatBrowserAutomationError(error: unknown): string {
if (error instanceof Error) {
return error.message;

View File

@@ -4,6 +4,7 @@ import {
startGatewayClientWhenEventLoopReady,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "./config.js";
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
@@ -30,13 +31,6 @@ type VoiceCallMeetJoinResult = {
introSent: boolean;
};
function sleep(ms: number): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createConnectedGatewayClient(
config: GoogleMeetConfig,
): Promise<VoiceCallGatewayClient> {

View File

@@ -1,4 +1,5 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -193,7 +194,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
return null;
}
const mimeType = inline?.mimeType ?? inline?.mime_type ?? DEFAULT_OUTPUT_MIME;
const extension = mimeType.includes("jpeg") ? "jpg" : (mimeType.split("/")[1] ?? "png");
const extension = extensionForMime(mimeType)?.slice(1) ?? "png";
imageIndex += 1;
return {
buffer: Buffer.from(data, "base64"),

View File

@@ -9,8 +9,7 @@
"type": "module",
"dependencies": {
"gaxios": "7.1.4",
"google-auth-library": "10.6.2",
"zod": "^4.4.3"
"google-auth-library": "10.6.2"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",

View File

@@ -9,7 +9,7 @@ import {
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import type { GoogleChatAccountConfig } from "./types.config.js";
type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";

View File

@@ -1,8 +1,8 @@
import { messagingApi } from "@line/bot-sdk";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { resolveLineAccount } from "./accounts.js";
import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js";
@@ -113,7 +113,7 @@ export async function uploadRichMenuImage(
const contentType =
media.contentType === "image/png" || media.contentType === "image/jpeg"
? media.contentType
: normalizeLowercaseStringOrEmpty(imagePath).endsWith(".png")
: mimeTypeFromFilePath(imagePath) === "image/png"
? "image/png"
: "image/jpeg";

View File

@@ -5,7 +5,6 @@
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
"dependencies": {
"ajv": "^8.20.0",
"typebox": "1.1.37"
},
"devDependencies": {

View File

@@ -1,31 +1,5 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("ajv", () => ({
default: class MockAjv {
compile(schema: unknown) {
return (value: unknown) => {
if (
schema &&
typeof schema === "object" &&
!Array.isArray(schema) &&
(schema as { properties?: Record<string, { type?: string }> }).properties?.foo?.type ===
"string"
) {
const ok = typeof (value as { foo?: unknown })?.foo === "string";
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = ok
? undefined
: [{ instancePath: "/foo", message: "must be string" }];
return ok;
}
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = undefined;
return true;
};
}
errors?: Array<{ instancePath: string; message: string }>;
},
}));
vi.mock("../api.js", async () => {
const actual = await vi.importActual<typeof import("../api.js")>("../api.js");
return {
@@ -35,7 +9,6 @@ vi.mock("../api.js", async () => {
});
afterAll(() => {
vi.doUnmock("ajv");
vi.doUnmock("../api.js");
vi.resetModules();
});
@@ -159,6 +132,45 @@ describe("llm-task tool (json-only)", () => {
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("validates caller schemas with repeated $id independently across calls", async () => {
const tool = createLlmTaskTool(fakeApi());
(runEmbeddedPiAgent as any)
.mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
})
.mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ count: 1 }) }],
});
await expect(
tool.execute("id", {
prompt: "return foo",
schema: {
$id: "https://example.test/llm-task-result",
type: "object",
properties: { foo: { type: "string" } },
required: ["foo"],
additionalProperties: false,
},
}),
).resolves.toMatchObject({ details: { json: { foo: "bar" } } });
await expect(
tool.execute("id", {
prompt: "return count",
schema: {
$id: "https://example.test/llm-task-result",
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
additionalProperties: false,
},
}),
).resolves.toMatchObject({ details: { json: { count: 1 } } });
});
it("throws on invalid json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},

View File

@@ -1,13 +1,14 @@
import path from "node:path";
import Ajv from "ajv";
import { buildModelAliasIndex, resolveModelRefFromString } from "openclaw/plugin-sdk/agent-runtime";
import {
type JsonSchemaObject,
validateJsonSchemaValue,
} from "openclaw/plugin-sdk/json-schema-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "../api.js";
import type { OpenClawPluginApi } from "../api.js";
const AjvCtor = Ajv as unknown as typeof import("ajv").default;
function stripCodeFences(s: string): string {
const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
@@ -293,17 +294,14 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
const schema = params.schema;
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
const ajv = new AjvCtor({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
const ok = validate(parsed);
if (!ok) {
const msg =
validate.errors
?.map(
(e: { instancePath?: string; message?: string }) =>
`${e.instancePath || "<root>"} ${e.message || "invalid"}`,
)
.join("; ") ?? "invalid";
const validation = validateJsonSchemaValue({
schema: schema as JsonSchemaObject,
cacheKey: "llm-task.result",
value: parsed,
cache: false,
});
if (!validation.ok) {
const msg = validation.errors.map((error) => error.text).join("; ") || "invalid";
throw new Error(`LLM JSON did not match schema: ${msg}`);
}
}

View File

@@ -633,6 +633,9 @@ describe("Matrix auth/config live surfaces", () => {
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
expect(validateMatrixHomeserverUrl("http://[::ffff:127.0.0.1]:8008")).toBe(
"http://[::ffff:127.0.0.1]:8008",
);
});
it("accepts internal http homeservers only when private-network access is enabled", () => {

View File

@@ -1,56 +1 @@
import net from "node:net";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function normalizeHost(host: string): string {
const normalized = normalizeLowercaseStringOrEmpty(host).replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}
function isPrivateIpv4(host: string): boolean {
const parts = host.split(".").map((part) => Number(part));
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false;
}
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function isPrivateIpv6(host: string): boolean {
if (host === "::1") {
return true;
}
if (host === "::" || host.startsWith("ff")) {
return false;
}
return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:");
}
export function isPrivateOrLoopbackHost(host: string): boolean {
const normalized = normalizeHost(host);
if (!normalized) {
return false;
}
if (normalized === "localhost") {
return true;
}
const family = net.isIP(normalized);
if (family === 4) {
return isPrivateIpv4(normalized);
}
if (family === 6) {
return isPrivateIpv6(normalized);
}
return false;
}
export { isPrivateOrLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -1,4 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
@@ -62,10 +63,6 @@ function resolveMatrixUserLocalpart(userId: string): string | null {
return trimmed.slice(1, colonIndex).trim() || null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveMatrixMentionPrefixCandidates(params: {
userId?: string | null;
displayName?: string | null;

View File

@@ -1,3 +1,4 @@
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
fetchWithSsrFGuard,
ssrfPolicyFromPrivateNetworkOptIn,
@@ -488,10 +489,6 @@ function readErrorCode(error: unknown): string | undefined {
return undefined;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function createMattermostPost(
client: MattermostClient,
params: {

View File

@@ -475,6 +475,7 @@ describe("createMattermostInteractionHandler", () => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const req = {
destroyed: false,
method: params.method ?? "POST",
headers: params.headers ?? {},
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
@@ -484,6 +485,18 @@ describe("createMattermostInteractionHandler", () => {
listeners.set(event, existing);
return this;
},
removeListener(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
listeners.set(
event,
existing.filter((entry) => entry !== handler),
);
return this;
},
destroy() {
this.destroyed = true;
return this;
},
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
req.emitTest = (event: string, ...args: unknown[]) => {

View File

@@ -7,7 +7,12 @@ import {
} from "openclaw/plugin-sdk/text-runtime";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
import {
isTrustedProxyAddress,
readRequestBodyWithLimit,
resolveClientIp,
type OpenClawConfig,
} from "./runtime-api.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
@@ -353,35 +358,9 @@ export function buildButtonProps(params: {
// ── Request body reader ────────────────────────────────────────────────
function readInteractionBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
const timer = setTimeout(() => {
req.destroy();
reject(new Error("Request body read timeout"));
}, INTERACTION_BODY_TIMEOUT_MS);
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
req.destroy();
clearTimeout(timer);
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
return readRequestBodyWithLimit(req, {
maxBytes: INTERACTION_MAX_BODY_BYTES,
timeoutMs: INTERACTION_BODY_TIMEOUT_MS,
});
}

View File

@@ -1,3 +1,4 @@
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
fetchMattermostUserTeams,
@@ -22,10 +23,6 @@ import {
} from "./slash-commands.js";
import { activateSlashCommands } from "./slash-state.js";
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function buildSlashCommands(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;

View File

@@ -3,6 +3,7 @@ import {
deliverWithFinalizableLivePreviewAdapter,
} from "openclaw/plugin-sdk/channel-message";
import { resolveChannelStreamingPreviewToolProgress } from "openclaw/plugin-sdk/channel-streaming";
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
@@ -147,10 +148,6 @@ type MattermostReaction = {
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function normalizeInteractionSourceIps(values?: string[]): string[] {
return (values ?? [])
.map((value) => normalizeOptionalString(value))

View File

@@ -9,13 +9,11 @@ import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime";
export function resolveHomePath(input: string): string {
if (input === "~") {
return os.homedir();
const trimmed = input.trim();
if (!trimmed) {
return trimmed;
}
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
return path.resolve(input);
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/u, os.homedir()));
}
export async function exists(filePath: string): Promise<boolean> {

View File

@@ -1,7 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
import { resolveHomePath } from "./helpers.js";
import { buildClaudeMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
@@ -22,6 +24,20 @@ describe("Claude migration provider", () => {
expect(provider.label).toBe("Claude");
});
it("resolves tilde source paths against the OS home when OPENCLAW_HOME is set", () => {
const previous = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = path.join(path.sep, "tmp", "openclaw-home");
try {
expect(resolveHomePath("~/.claude")).toBe(path.join(os.homedir(), ".claude"));
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previous;
}
}
});
it("rejects missing Claude sources before planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "missing");

View File

@@ -10,13 +10,11 @@ import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runt
import { parse as parseYaml } from "yaml";
export function resolveHomePath(input: string): string {
if (input === "~") {
return os.homedir();
const trimmed = input.trim();
if (!trimmed) {
return trimmed;
}
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
return path.resolve(input);
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/u, os.homedir()));
}
export async function exists(filePath: string): Promise<boolean> {

View File

@@ -1,6 +1,8 @@
import os from "node:os";
import path from "node:path";
import { createCapturedPluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { resolveHomePath } from "./helpers.js";
import pluginEntry from "./index.js";
import { HERMES_REASON_INCLUDE_SECRETS } from "./items.js";
import { buildHermesMigrationProvider } from "./provider.js";
@@ -17,6 +19,20 @@ describe("Hermes migration provider", () => {
expect(captured.migrationProviders.map((provider) => provider.id)).toEqual(["hermes"]);
});
it("resolves tilde source paths against the OS home when OPENCLAW_HOME is set", () => {
const previous = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = path.join(path.sep, "tmp", "openclaw-home");
try {
expect(resolveHomePath("~/.hermes")).toBe(path.join(os.homedir(), ".hermes"));
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previous;
}
}
});
it("detects Hermes sources supported by planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");

View File

@@ -56,8 +56,8 @@ describe("minimax video generation provider", () => {
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
const provider = buildMinimaxVideoGenerationProvider();
@@ -80,6 +80,7 @@ describe("minimax video generation provider", () => {
}),
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual(
expect.objectContaining({
taskId: "task-123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -217,7 +218,7 @@ async function downloadVideoFromUrl(params: {
return {
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}
@@ -263,7 +264,7 @@ async function downloadVideoFromFileId(params: {
mimeType,
fileName:
normalizeOptionalString(metadata.file?.filename) ||
`video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
`video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}

View File

@@ -48,7 +48,7 @@ describe("isPrivateOrReservedIP", () => {
["fe80::", true],
["fc00::1", true],
["fd12:3456::1", true],
["2001:0db8::1", false],
["2001:0db8::1", true],
["2620:1ec:c11::200", false],
// IPv4-mapped IPv6 addresses
["::ffff:127.0.0.1", true],
@@ -62,9 +62,9 @@ describe("isPrivateOrReservedIP", () => {
});
it.each([
["999.999.999.999", false],
["256.0.0.1", false],
["10.0.0.256", false],
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
] as const)("malformed IPv4 %s → %s", (ip, expected) => {

View File

@@ -9,12 +9,10 @@
*/
import { lookup } from "node:dns/promises";
import { isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { buildUserAgent } from "./user-agent.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/**
* Allowlist of domains that are valid targets for file consent uploads.
* These are the Microsoft/SharePoint domains that Teams legitimately provides
@@ -36,72 +34,10 @@ export const CONSENT_UPLOAD_HOST_ALLOWLIST = [
] as const;
/**
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
* or link-local range that must never be reached via consent uploads.
* Returns true if the given IPv4 or IPv6 address is private, internal, or
* special-use and must never be reached via consent uploads.
*/
export function isPrivateOrReservedIP(ip: string): boolean {
// Handle IPv4-mapped IPv6 first (e.g., ::ffff:127.0.0.1, ::ffff:10.0.0.1)
const ipv4MappedMatch = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
if (ipv4MappedMatch) {
return isPrivateOrReservedIP(ipv4MappedMatch[1]);
}
// IPv4 checks
const v4Parts = ip.split(".");
if (v4Parts.length === 4) {
const octets = v4Parts.map(Number);
// Validate all octets are integers in 0-255
if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
return false;
}
const [a, b] = octets;
// 10.0.0.0/8
if (a === 10) {
return true;
}
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
// 192.168.0.0/16
if (a === 192 && b === 168) {
return true;
}
// 127.0.0.0/8 (loopback)
if (a === 127) {
return true;
}
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) {
return true;
}
// 0.0.0.0/8
if (a === 0) {
return true;
}
}
// IPv6 checks
const normalized = normalizeLowercaseStringOrEmpty(ip);
// ::1 loopback
if (normalized === "::1") {
return true;
}
// fe80::/10 link-local
if (normalized.startsWith("fe80:") || normalized.startsWith("fe80")) {
return true;
}
// fc00::/7 unique-local (fc00:: and fd00::)
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
return true;
}
// :: unspecified
if (normalized === "::") {
return true;
}
return false;
}
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Validate that a consent upload URL is safe to PUT to.

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveMSTeamsStorePath } from "./storage.js";
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
@@ -47,18 +48,6 @@ const STORE_FILENAME = "msteams-polls.json";
const MAX_POLLS = 1000;
const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function normalizeChoiceValue(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();

View File

@@ -7,9 +7,6 @@
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"

View File

@@ -7,7 +7,7 @@ import {
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {

View File

@@ -8,8 +8,7 @@
},
"type": "module",
"dependencies": {
"nostr-tools": "^2.23.3",
"zod": "^4.4.3"
"nostr-tools": "^2.23.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",

View File

@@ -8,6 +8,11 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
readStringValue,
} from "openclaw/plugin-sdk/text-runtime";
import { z } from "openclaw/plugin-sdk/zod";
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
@@ -24,22 +29,6 @@ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
// Types
// ============================================================================
function readStringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function normalizeOptionalLowercaseString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return normalizeOptionalLowercaseString(value) ?? "";
}
export interface NostrProfileHttpContext {
/** Get current profile from config */
getConfigProfile: (accountId: string) => NostrProfile | undefined;

View File

@@ -2,7 +2,7 @@ import os from "node:os";
import path from "node:path";
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { privateFileStore } from "openclaw/plugin-sdk/security-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import { getNostrRuntime } from "./runtime.js";
const STORE_VERSION = 2;

View File

@@ -647,7 +647,6 @@ describe("openai image generation provider", () => {
{
buffer: Buffer.from("jpeg-bytes"),
mimeType: "image/jpeg",
fileName: "style.jpg",
},
],
});
@@ -675,7 +674,7 @@ describe("openai image generation provider", () => {
expect(images).toHaveLength(2);
expect(images[0]?.name).toBe("reference.png");
expect(images[0]?.type).toBe("image/png");
expect(images[1]?.name).toBe("style.jpg");
expect(images[1]?.name).toBe("image-2.jpg");
expect(images[1]?.type).toBe("image/jpeg");
expect(postJsonRequestMock).not.toHaveBeenCalledWith(
expect.objectContaining({ url: "https://api.openai.com/v1/images/edits" }),

View File

@@ -11,6 +11,7 @@ import {
} from "openclaw/plugin-sdk/image-generation";
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveClosestSize } from "openclaw/plugin-sdk/media-generation-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
ensureAuthProfileStore,
isProviderApiKeyConfigured,
@@ -387,7 +388,7 @@ function inferImageUploadFileName(params: {
return path.basename(fileName);
}
const mimeType = params.mimeType?.trim().toLowerCase() || DEFAULT_OUTPUT_MIME;
const ext = mimeType === "image/jpeg" ? "jpg" : mimeType.replace(/^image\//, "") || "png";
const ext = extensionForMime(mimeType)?.slice(1) ?? "png";
return `image-${params.index + 1}.${ext}`;
}

View File

@@ -49,8 +49,8 @@ describe("openai video generation provider", () => {
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
const provider = buildOpenAIVideoGenerationProvider();
@@ -75,7 +75,8 @@ describe("openai video generation provider", () => {
fetch,
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.videos[0]?.mimeType).toBe("video/webm");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual(
expect.objectContaining({
videoId: "vid_123",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -104,13 +105,8 @@ function resolveReferenceAsset(req: VideoGenerationRequest) {
const mimeType =
normalizeOptionalString(asset.mimeType) ||
((req.inputVideos?.length ?? 0) > 0 ? "video/mp4" : "image/png");
const extension = mimeType.includes("video")
? "mp4"
: mimeType.includes("jpeg")
? "jpg"
: mimeType.includes("webp")
? "webp"
: "png";
const extension =
extensionForMime(mimeType)?.slice(1) ?? (mimeType.startsWith("video/") ? "mp4" : "png");
const fileName =
normalizeOptionalString(asset.fileName) ||
`${(req.inputVideos?.length ?? 0) > 0 ? "reference-video" : "reference-image"}.${extension}`;
@@ -173,7 +169,7 @@ async function downloadOpenAIVideo(params: {
return {
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
}

View File

@@ -295,7 +295,7 @@ describe("openrouter video generation provider", () => {
}),
);
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }),
releasedVideo({ contentType: "video/webm", bytes: "webm-bytes" }),
);
const provider = buildOpenRouterVideoGenerationProvider();
@@ -313,7 +313,8 @@ describe("openrouter video generation provider", () => {
expect.any(Function),
expect.objectContaining({ auditContext: "openrouter-video-download" }),
);
expect(result.videos[0]?.buffer?.toString()).toBe("mp4-bytes");
expect(result.videos[0]?.buffer?.toString()).toBe("webm-bytes");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
});
it("rejects video reference inputs", async () => {

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -322,7 +323,7 @@ async function downloadOpenRouterVideo(params: {
return {
buffer,
mimeType,
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
};
} finally {
await release();

View File

@@ -4,6 +4,10 @@ import {
resolveProviderWebSearchPluginConfig,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
@@ -59,14 +63,6 @@ export function resolvePerplexityWebSearchRuntimeMetadata(
};
}
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return trimToUndefined(value)?.toLowerCase() ?? "";
}
export function inferPerplexityBaseUrlFromApiKey(
apiKey?: string,
): "direct" | "openrouter" | undefined {
@@ -101,8 +97,8 @@ function resolvePerplexityRuntimeTransport(
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const configuredBaseUrl = trimToUndefined(scoped?.baseUrl) ?? "";
const configuredModel = trimToUndefined(scoped?.model) ?? "";
const configuredBaseUrl = normalizeOptionalString(scoped?.baseUrl) ?? "";
const configuredModel = normalizeOptionalString(scoped?.model) ?? "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;

View File

@@ -8,8 +8,7 @@
"@copilotkit/aimock": "1.17.0",
"@modelcontextprotocol/sdk": "1.29.0",
"playwright-core": "1.59.1",
"yaml": "^2.8.4",
"zod": "^4.4.3"
"yaml": "^2.8.4"
},
"devDependencies": {
"@openclaw/discord": "workspace:*",

View File

@@ -1,3 +1,5 @@
import { sleep } from "openclaw/plugin-sdk/runtime-env";
type QaBrowserGateway = {
call: (
method: string,
@@ -180,10 +182,6 @@ function isQaBrowserReady(status: QaBrowserStatus | null | undefined) {
return status?.enabled === true && status?.running === true && status?.cdpReady === true;
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export async function waitForQaBrowserReady<T extends QaBrowserStatus = QaBrowserStatus>(
env: QaBrowserEnv,
params: QaBrowserReadyParams = {},

View File

@@ -1,6 +1,7 @@
import { Agent, createServer, request } from "node:http";
import { describe, expect, it } from "vitest";
import { closeQaHttpServer } from "./bus-server.js";
import { closeQaHttpServer, handleQaBusRequest } from "./bus-server.js";
import { createQaBusState } from "./bus-state.js";
async function listenOnLoopback(server: ReturnType<typeof createServer>): Promise<number> {
await new Promise<void>((resolve, reject) => {
@@ -57,3 +58,37 @@ describe("closeQaHttpServer", () => {
}
});
});
describe("handleQaBusRequest", () => {
it("returns a controlled error when a v1 POST body exceeds the limit", async () => {
const req = {
method: "POST",
url: "/v1/reset",
headers: { "content-length": String(1024 * 1024 + 1) },
destroyed: false,
destroy() {
this.destroyed = true;
},
};
const res = {
statusCode: 0,
body: "",
writeHead(statusCode: number) {
this.statusCode = statusCode;
},
end(payload: string) {
this.body = payload;
},
};
const handled = await handleQaBusRequest({
req: req as never,
res: res as never,
state: createQaBusState(),
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body)).toEqual({ error: "Payload too large" });
});
});

View File

@@ -1,5 +1,10 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress";
import { normalizeAccountId } from "./bus-queries.js";
import type { QaBusState } from "./bus-state.js";
import type {
@@ -15,12 +20,16 @@ import type {
QaBusWaitForInput,
} from "./runtime-api.js";
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8").trim();
const QA_HTTP_JSON_MAX_BODY_BYTES = 1024 * 1024;
const QA_HTTP_JSON_BODY_TIMEOUT_MS = 5_000;
export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
const text = (
await readRequestBodyWithLimit(req, {
maxBytes: QA_HTTP_JSON_MAX_BODY_BYTES,
timeoutMs: QA_HTTP_JSON_BODY_TIMEOUT_MS,
})
).trim();
return text ? (JSON.parse(text) as unknown) : {};
}
@@ -39,6 +48,14 @@ export function writeError(res: ServerResponse, statusCode: number, error: unkno
});
}
export function writeQaRequestBodyLimitError(res: ServerResponse, error: unknown): boolean {
if (!isRequestBodyLimitError(error)) {
return false;
}
writeError(res, error.statusCode, requestBodyErrorToText(error.code));
return true;
}
export async function closeQaHttpServer(server: Server): Promise<void> {
let forceCloseTimer: NodeJS.Timeout | undefined;
try {
@@ -84,9 +101,8 @@ export async function handleQaBusRequest(params: {
return true;
}
const body = (await readJson(params.req)) as Record<string, unknown>;
try {
const body = (await readQaJsonBody(params.req)) as Record<string, unknown>;
switch (url.pathname) {
case "/v1/reset":
params.state.reset();
@@ -163,6 +179,9 @@ export async function handleQaBusRequest(params: {
return true;
}
} catch (error) {
if (writeQaRequestBodyLimitError(params.res, error)) {
return true;
}
writeError(params.res, 400, error);
return true;
}

View File

@@ -1,3 +1,4 @@
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
import { QA_PROVIDER_SECRET_ENV_VARS } from "./providers/env.js";
const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([
@@ -14,7 +15,7 @@ const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
export function redactQaGatewayDebugText(text: string) {
let redacted = text;
for (const envVar of QA_GATEWAY_DEBUG_SECRET_ENV_VARS) {
const escapedEnvVar = envVar.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedEnvVar = escapeRegExp(envVar);
redacted = redacted.replace(
new RegExp(`\\b(${escapedEnvVar})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "g"),
`$1$2<redacted>`,
@@ -25,7 +26,7 @@ export function redactQaGatewayDebugText(text: string) {
);
}
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2<redacted>`,

View File

@@ -4,7 +4,12 @@ import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startQaLabServer, type QaLabServerStartParams } from "./lab-server.js";
import { readQaJsonBody } from "./bus-server.js";
import {
startQaLabServer,
writeQaLabServerError,
type QaLabServerStartParams,
} from "./lab-server.js";
vi.mock("@openclaw/qa-channel/api.js", async () => await import("../../qa-channel/api.js"));
@@ -314,6 +319,38 @@ describe("qa-lab server", () => {
await expect(readFile(outputPath, "utf8")).rejects.toThrow();
});
it("returns controlled errors for oversized JSON body reads", async () => {
const req = {
headers: { "content-length": String(1024 * 1024 + 1) },
destroyed: false,
destroy() {
this.destroyed = true;
},
};
const res = {
statusCode: 0,
body: "",
writeHead(statusCode: number) {
this.statusCode = statusCode;
},
end(payload: string) {
this.body = payload;
},
};
let error: unknown;
try {
await readQaJsonBody(req as never);
} catch (caught) {
error = caught;
}
writeQaLabServerError(res as never, error);
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body)).toEqual({ error: "Payload too large" });
});
it("anchors direct self-check runs under the explicit repo root by default", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-self-check-root-"));
cleanups.push(async () => {

View File

@@ -1,12 +1,19 @@
import fs from "node:fs";
import { createServer, type IncomingMessage } from "node:http";
import { createServer } from "node:http";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
acquireDebugProxyCaptureStore,
resolveDebugProxySettings,
} from "openclaw/plugin-sdk/proxy-capture";
import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js";
import {
closeQaHttpServer,
handleQaBusRequest,
readQaJsonBody,
writeError,
writeJson,
writeQaRequestBodyLimitError,
} from "./bus-server.js";
import { createQaBusState, type QaBusState } from "./bus-state.js";
import { createQaRunnerRuntime } from "./harness-runtime.js";
import {
@@ -57,6 +64,13 @@ export type {
QaLabServerStartParams,
} from "./lab-server.types.js";
export function writeQaLabServerError(res: Parameters<typeof writeError>[0], error: unknown): void {
if (writeQaRequestBodyLimitError(res, error)) {
return;
}
writeError(res, 500, error);
}
function countQaLabScenarioRun(scenarios: QaLabScenarioOutcome[]) {
return {
total: scenarios.length,
@@ -94,15 +108,6 @@ function injectKickoffMessage(params: {
});
}
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8").trim();
return text ? (JSON.parse(text) as unknown) : {};
}
function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefaults {
if (autoKickoffTarget === "channel") {
return {
@@ -420,7 +425,7 @@ export async function startQaLabServer(
return;
}
if (req.method === "POST" && url.pathname === "/api/capture/delete-sessions") {
const body = (await readJson(req)) as { sessionIds?: unknown };
const body = (await readQaJsonBody(req)) as { sessionIds?: unknown };
const sessionIds = Array.isArray(body.sessionIds)
? body.sessionIds.filter((value): value is string => typeof value === "string")
: [];
@@ -455,7 +460,7 @@ export async function startQaLabServer(
return;
}
if (req.method === "POST" && url.pathname === "/api/inbound/message") {
const body = await readJson(req);
const body = await readQaJsonBody(req);
writeJson(res, 200, {
message: state.addInboundMessage(body as Parameters<QaBusState["addInboundMessage"]>[0]),
});
@@ -485,7 +490,10 @@ export async function startQaLabServer(
writeError(res, 409, "QA suite run already in progress");
return;
}
const selection = normalizeQaRunSelection(await readJson(req), scenarioCatalog.scenarios);
const selection = normalizeQaRunSelection(
await readQaJsonBody(req),
scenarioCatalog.scenarios,
);
state.reset();
latestReport = null;
latestScenarioRun = null;
@@ -573,7 +581,7 @@ export async function startQaLabServer(
}
res.end(body);
} catch (error) {
writeError(res, 500, error);
writeQaLabServerError(res, error);
}
});

View File

@@ -11,8 +11,8 @@ import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { z } from "openclaw/plugin-sdk/zod";
import { chromium } from "playwright-core";
import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import {
isQaCredentialTruthyOptIn,
joinQaCredentialEndpoint,

View File

@@ -5,7 +5,7 @@ import { createSlackWebClient, createSlackWriteClient } from "@openclaw/slack/ap
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {

View File

@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {

View File

@@ -8,7 +8,7 @@ import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {

View File

@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import type { QaProviderMode } from "./model-selection.js";
@@ -114,10 +115,6 @@ function createVmSuffix() {
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function execFileAsync(file: string, args: string[], options: ExecFileOptions = {}) {
return new Promise<ExecResult>((resolve, reject) => {
execFile(

View File

@@ -1,5 +1,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
import { closeQaHttpServer } from "../../bus-server.js";
type ResponsesInputItem = Record<string, unknown>;
@@ -173,12 +175,13 @@ type MockScenarioState = {
subagentFanoutPhase: number;
};
const MOCK_OPENAI_MAX_BODY_BYTES = 16 * 1024 * 1024;
const MOCK_OPENAI_BODY_TIMEOUT_MS = 30_000;
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
return readRequestBodyWithLimit(req, {
maxBytes: MOCK_OPENAI_MAX_BODY_BYTES,
timeoutMs: MOCK_OPENAI_BODY_TIMEOUT_MS,
});
}
@@ -577,7 +580,7 @@ function extractExactMarkerDirective(text: string) {
}
function extractLabeledMarkerDirective(text: string, label: string) {
const escapedLabel = label.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedLabel = escapeRegExp(label);
const backtickedMatch = extractLastCapture(
text,
new RegExp(`${escapedLabel}:\\s*\`([^\\\`]+)\``, "i"),
@@ -592,12 +595,12 @@ function extractLabeledMarkerDirective(text: string, label: string) {
}
function extractQuotedToolArg(text: string, name: string) {
const escapedName = name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedName = escapeRegExp(name);
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*"([^"]+)"`, "i"));
}
function extractBareToolArg(text: string, name: string) {
const escapedName = name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedName = escapeRegExp(name);
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*([^\\s\\\`.,;:!?]+)`, "i"));
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
import {
joinQaCredentialEndpoint,
normalizeQaCredentialConvexSiteUrl,

View File

@@ -1,3 +1,5 @@
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
export const QA_CREDENTIALS_DEFAULT_ENDPOINT_PREFIX = "/qa-credentials/v1";
const QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY = "OPENCLAW_QA_ALLOW_INSECURE_HTTP";
@@ -29,10 +31,6 @@ export function isQaCredentialTruthyOptIn(value: string | undefined) {
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function isQaCredentialLoopbackHostname(hostname: string) {
return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
}
export function normalizeQaCredentialConvexSiteUrl(params: {
env: NodeJS.ProcessEnv;
raw: string;
@@ -57,7 +55,7 @@ export function normalizeQaCredentialConvexSiteUrl(params: {
const allowInsecureHttp = isQaCredentialTruthyOptIn(
params.env[QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY],
);
if (!allowInsecureHttp || !isQaCredentialLoopbackHostname(url.hostname)) {
if (!allowInsecureHttp || !isLoopbackHost(url.hostname)) {
throw toError(
`OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY}=1.`,
);

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { z } from "openclaw/plugin-sdk/zod";
import YAML from "yaml";
import { z } from "zod";
export const DEFAULT_QA_AGENT_IDENTITY_MARKDOWN = `# Dev C-3PO

View File

@@ -12,8 +12,7 @@
"@tencent-connect/qqbot-connector": "^1.1.0",
"mpg123-decoder": "^1.0.3",
"silk-wasm": "^3.7.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
"ws": "^8.20.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",

View File

@@ -3,7 +3,7 @@ import {
buildChannelConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
import { z } from "openclaw/plugin-sdk/zod";
const AudioFormatPolicySchema = z
.object({

View File

@@ -5,27 +5,17 @@
* across `outbound.ts`. Centralizing them here keeps detection consistent.
*/
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
/**
* Extract a lowercase file extension from a path or URL, ignoring query and hash.
*/
function getCleanExtension(filePath: string): string {
const cleanPath = filePath.split("?")[0].split("#")[0];
const lastDot = cleanPath.lastIndexOf(".");
if (lastDot < 0) {
return "";
}
return cleanPath.slice(lastDot).toLowerCase();
}
/** Check whether a file is an image using MIME first and extension as fallback. */
export function isImageFile(filePath: string, mimeType?: string): boolean {
if (mimeType?.startsWith("image/")) {
return true;
}
return IMAGE_EXTENSIONS.has(getCleanExtension(filePath));
return IMAGE_EXTENSIONS.has(getFileExtension(filePath) ?? "");
}
/** Check whether a file is a video using MIME first and extension as fallback. */
@@ -33,5 +23,5 @@ export function isVideoFile(filePath: string, mimeType?: string): boolean {
if (mimeType?.startsWith("video/")) {
return true;
}
return VIDEO_EXTENSIONS.has(getCleanExtension(filePath));
return VIDEO_EXTENSIONS.has(getFileExtension(filePath) ?? "");
}

View File

@@ -18,9 +18,27 @@ import {
checkFileSize,
downloadFile,
fileExistsAsync,
getImageMimeType,
getMimeType,
readFileAsync,
} from "./file-utils.js";
describe("qqbot file-utils MIME helpers", () => {
it("uses the shared media MIME table for extension inference", () => {
expect(getMimeType("voice.mp3")).toBe("audio/mpeg");
expect(getMimeType("clip.webm")).toBe("video/webm");
expect(getMimeType("clip.avi")).toBe("video/x-msvideo");
expect(getMimeType("clip.mkv")).toBe("video/x-matroska");
expect(getMimeType("archive.unknown")).toBe("application/octet-stream");
});
it("keeps the image-only gate for image MIME inference", () => {
expect(getImageMimeType("photo.PNG")).toBe("image/png");
expect(getImageMimeType("clip.webm")).toBeNull();
expect(getImageMimeType("archive.unknown")).toBeNull();
});
});
describe("qqbot file-utils downloadFile", () => {
let tempDir: string;

View File

@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import {
openLocalFileSafely,
readRegularFile,
@@ -10,7 +11,7 @@ import { getPlatformAdapter } from "../adapter/index.js";
import type { SsrfPolicyConfig } from "../adapter/types.js";
import { MediaFileType } from "../types.js";
import { formatErrorMessage } from "./format.js";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js";
import { normalizeOptionalString } from "./string-normalize.js";
/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
@@ -133,34 +134,9 @@ export function formatFileSize(bytes: number): string {
/** Infer a MIME type from the file extension. */
export function getMimeType(filePath: string): string {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
return MIME_TYPES[ext] ?? "application/octet-stream";
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
}
/** Canonical ext → MIME table. Single source of truth. */
const MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".webm": "video/webm",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".txt": "text/plain",
};
/** Extensions accepted as image uploads by the QQ Bot media pipeline. */
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
@@ -173,11 +149,12 @@ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bm
* `data:image/...;base64,` URL).
*/
export function getImageMimeType(filePath: string): string | null {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const ext = path.extname(filePath).toLowerCase();
if (!IMAGE_EXTENSIONS.has(ext)) {
return null;
}
return MIME_TYPES[ext] ?? null;
const mime = mimeTypeFromFilePath(filePath);
return mime?.startsWith("image/") ? mime : null;
}
/** Download a remote file into a local directory. */

View File

@@ -7,6 +7,8 @@
import * as fs from "node:fs";
import path from "node:path";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeOptionalString,
asOptionalObjectRecord as asRecord,
@@ -72,29 +74,30 @@ export async function transcribeAudio(
const fileBuffer = fs.readFileSync(audioPath);
const fileName = sanitizeFileName(path.basename(audioPath));
const mime = fileName.endsWith(".wav")
? "audio/wav"
: fileName.endsWith(".mp3")
? "audio/mpeg"
: fileName.endsWith(".ogg")
? "audio/ogg"
: "application/octet-stream";
const mime = mimeTypeFromFilePath(fileName) ?? "application/octet-stream";
const form = new FormData();
form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
form.append("model", sttCfg.model);
const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
method: "POST",
headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
body: form,
const { response: resp, release } = await fetchWithSsrFGuard({
url: `${sttCfg.baseUrl}/audio/transcriptions`,
auditContext: "qqbot-stt",
init: {
method: "POST",
headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
body: form,
},
});
try {
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
}
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
const result = (await resp.json()) as { text?: string };
return normalizeOptionalString(result.text) ?? null;
} finally {
await release();
}
const result = (await resp.json()) as { text?: string };
return normalizeOptionalString(result.text) ?? null;
}

View File

@@ -40,7 +40,7 @@ describe("runway video generation provider", () => {
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
headers: new Headers({ "content-type": "video/webm" }),
});
const provider = buildRunwayVideoGenerationProvider();
@@ -72,6 +72,7 @@ describe("runway video generation provider", () => {
fetch,
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual(
expect.objectContaining({
taskId: "task-1",

View File

@@ -1,3 +1,4 @@
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
@@ -258,7 +259,7 @@ async function downloadRunwayVideos(params: {
videos.push({
buffer: Buffer.from(arrayBuffer),
mimeType,
fileName: `video-${index + 1}.${mimeType.includes("webm") ? "webm" : "mp4"}`,
fileName: `video-${index + 1}.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`,
metadata: { sourceUrl: url },
});
}

View File

@@ -1,5 +1,9 @@
import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import { formatSlackFileReference } from "../file-reference.js";
import type { SlackAttachment, SlackFile } from "../types.js";
export { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js";
@@ -18,15 +22,6 @@ export {
type SlackThreadStarter,
} from "./thread.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeOptionalLowercaseString(value: unknown): string | undefined {
const normalized = normalizeLowercaseStringOrEmpty(value);
return normalized || undefined;
}
function isSlackHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
if (!normalized) {

View File

@@ -38,7 +38,7 @@ import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runti
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
@@ -80,10 +80,6 @@ import {
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
import type { PreparedSlackMessage } from "./types.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Slack reactions.add/remove expect shortcode names, not raw unicode emoji.
const UNICODE_TO_SLACK: Record<string, string> = {
"👀": "eyes",

View File

@@ -7,9 +7,6 @@
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},

View File

@@ -25,6 +25,7 @@ import {
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { synologyChatApprovalAuth } from "./approval-auth.js";
import { sendMessage, sendFileUrl } from "./client.js";
@@ -40,10 +41,6 @@ import type { ResolvedSynologyChatAccount } from "./types.js";
const CHANNEL_ID = "synology-chat";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,

View File

@@ -6,19 +6,17 @@
import * as http from "node:http";
import * as https from "node:https";
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
formatErrorMessage,
resolvePinnedHostnameWithPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { z } from "zod";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { z } from "openclaw/plugin-sdk/zod";
const MIN_SEND_INTERVAL_MS = 500;
let lastSendTime = 0;
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
// --- Chat user_id resolution ---
// Synology Chat uses two different user_id spaces:
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
@@ -329,7 +327,3 @@ function doPost(url: string, body: string, allowInsecureSsl = false): Promise<bo
req.end();
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -11,6 +11,7 @@ import {
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { listAccountIds, resolveAccount } from "./accounts.js";
import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js";
@@ -34,14 +35,6 @@ const SYNOLOGY_ALLOW_FROM_HELP_LINES = [
`Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`,
];
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig {
return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {};
}

View File

@@ -5,6 +5,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import * as querystring from "node:querystring";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
@@ -16,10 +17,6 @@ import * as synologyClient from "./client.js";
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
// One rate limiter per account, created lazily
const rateLimiters = new Map<string, RateLimiter>();
const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();

View File

@@ -1,14 +1 @@
import { Type } from "typebox";
export function optionalStringEnum<const T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Optional(
Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
}),
);
}
export { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";

View File

@@ -1,11 +1,8 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { normalizeTelegramLookupTarget, parseTelegramTarget } from "./targets.js";
const TELEGRAM_PREFIX_RE = /^(telegram|tg):/i;
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeTelegramTargetBody(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {

View File

@@ -1,5 +1,5 @@
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { escapeRegExp, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
definePluginEntry,
fetchWithSsrFGuard,
@@ -29,10 +29,6 @@ function resolveThreadToken(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveSlackConversationId(value: unknown): string {
const raw = normalizeOptionalString(value) ?? "";
if (!raw) {

View File

@@ -7,16 +7,13 @@
// Extensions cannot import core internals directly, so use node:crypto here.
import { randomBytes } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { PendingApproval } from "../settings.js";
export type { PendingApproval };
export type ApprovalType = "dm" | "channel" | "group";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export type CreateApprovalParams = {
type: ApprovalType;
requestingShip: string;

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import * as path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
fetchRemoteMedia,
MAX_IMAGE_BYTES,
@@ -118,19 +119,7 @@ function getExtensionFromFileName(fileName?: string): string | null {
}
function getExtensionFromContentType(contentType: string): string | null {
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"video/mp4": "mp4",
"video/webm": "webm",
"audio/mpeg": "mp3",
"audio/ogg": "ogg",
};
return map[contentType.split(";")[0].trim()] ?? null;
return extensionForMime(contentType)?.replace(/^\./u, "") ?? null;
}
function getExtensionFromUrl(url: string): string | null {

Some files were not shown because too many files have changed in this diff Show More