diff --git a/CHANGELOG.md b/CHANGELOG.md index f9000692ac5..40a7cd4ef73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8a74d275704..0ee80fcaec1 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 016bf556ab4..4e02d731c57 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | diff --git a/extensions/browser/src/browser/request-policy.test.ts b/extensions/browser/src/browser/request-policy.test.ts index 1cf17d75556..f5d032a4c61 100644 --- a/extensions/browser/src/browser/request-policy.test.ts +++ b/extensions/browser/src/browser/request-policy.test.ts @@ -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", () => { diff --git a/extensions/browser/src/browser/url-pattern.ts b/extensions/browser/src/browser/url-pattern.ts index 2ff99657d26..a2ae1c30b91 100644 --- a/extensions/browser/src/browser/url-pattern.ts +++ b/extensions/browser/src/browser/url-pattern.ts @@ -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); } diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index 4a6e180c6cb..a2659acaa98 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -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", diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 2e817588615..b65e7a05ddd 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -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"}`, }; } diff --git a/extensions/canvas/package.json b/extensions/canvas/package.json index f224f9e9ef4..d93314e9c31 100644 --- a/extensions/canvas/package.json +++ b/extensions/canvas/package.json @@ -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": { diff --git a/extensions/codex/package.json b/extensions/codex/package.json index b46461c91c7..99387303232 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -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:*" diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 121d3e34491..3d78ff9fafe 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -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); diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 900c9759264..6ea562128b8 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -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(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, }); } diff --git a/extensions/deepinfra/video-generation-provider.test.ts b/extensions/deepinfra/video-generation-provider.test.ts index f14167eff59..9daea7292fc 100644 --- a/extensions/deepinfra/video-generation-provider.test.ts +++ b/extensions/deepinfra/video-generation-provider.test.ts @@ -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")); + }); }); diff --git a/extensions/deepinfra/video-generation-provider.ts b/extensions/deepinfra/video-generation-provider.ts index f525f084b51..1125f20c428 100644 --- a/extensions/deepinfra/video-generation-provider.ts +++ b/extensions/deepinfra/video-generation-provider.ts @@ -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, diff --git a/extensions/diffs/src/language-hints.ts b/extensions/diffs/src/language-hints.ts index b076324a883..a8bde8cd4e4 100644 --- a/extensions/diffs/src/language-hints.ts +++ b/extensions/diffs/src/language-hints.ts @@ -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(["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 { diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index 2edefeec1f9..7e16b915edf 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -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( - values: T, - description: string, - options: { deprecated?: boolean } = {}, -) { - return Type.Unsafe({ - 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, }), ), diff --git a/extensions/discord/src/voice/sanitize.ts b/extensions/discord/src/voice/sanitize.ts index 6d19093672e..8937f84c5a2 100644 --- a/extensions/discord/src/voice/sanitize.ts +++ b/extensions/discord/src/voice/sanitize.ts @@ -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, " ") diff --git a/extensions/fal/video-generation-provider.test.ts b/extensions/fal/video-generation-provider.test.ts index 71ee6444384..8de70de71b6 100644 --- a/extensions/fal/video-generation-provider.test.ts +++ b/extensions/fal/video-generation-provider.test.ts @@ -58,6 +58,7 @@ describe("fal video generation provider", () => { responseUrl: string; videoUrl: string; bytes: string; + contentType?: string; responseExtras?: Record; }) { 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 { @@ -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", diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index 1da5effd1d1..1a00daaf991 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -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(); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index fb72a7a59f2..61ff8e8f0b9 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -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); diff --git a/extensions/file-transfer/src/node-host/file-fetch.test.ts b/extensions/file-transfer/src/node-host/file-fetch.test.ts index 6f4ffd08b35..d75d72bee4d 100644 --- a/extensions/file-transfer/src/node-host/file-fetch.test.ts +++ b/extensions/file-transfer/src/node-host/file-fetch.test.ts @@ -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", () => { diff --git a/extensions/file-transfer/src/node-host/file-fetch.ts b/extensions/file-transfer/src/node-host/file-fetch.ts index 3edf53a589d..a8eceaa5b0f 100644 --- a/extensions/file-transfer/src/node-host/file-fetch.ts +++ b/extensions/file-transfer/src/node-host/file-fetch.ts @@ -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 { + 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 { const requestedPath = params.path; if (typeof requestedPath !== "string" || requestedPath.length === 0) { @@ -196,7 +210,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise { 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", () => { diff --git a/extensions/file-transfer/src/shared/mime.ts b/extensions/file-transfer/src/shared/mime.ts index c0949438614..df7e6624bed 100644 --- a/extensions/file-transfer/src/shared/mime.ts +++ b/extensions/file-transfer/src/shared/mime.ts @@ -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 = { - ".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"; } diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts index c741b82c3da..9efbceca4a9 100644 --- a/extensions/firecrawl/src/firecrawl-scrape-tool.ts +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -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( - values: T, - options: { description?: string } = {}, -) { - return Type.Optional( - Type.Unsafe({ - type: "string", - enum: [...values], - ...options, - }), - ); -} - const FirecrawlScrapeToolSchema = Type.Object( { url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }), diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 0bcc6e01f0a..64b41c7e1e4 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function isManagedChromeBrowserSession(session: GoogleMeetSession): boolean { return Boolean( (session.transport === "chrome" || session.transport === "chrome-node") && diff --git a/extensions/google-meet/src/transports/chrome-create.ts b/extensions/google-meet/src/transports/chrome-create.ts index ab813d0ad30..519ee23a0f8 100644 --- a/extensions/google-meet/src/transports/chrome-create.ts +++ b/extensions/google-meet/src/transports/chrome-create.ts @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function formatBrowserAutomationError(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/extensions/google-meet/src/voice-call-gateway.ts b/extensions/google-meet/src/voice-call-gateway.ts index aa7f10acb3b..b3fd202781b 100644 --- a/extensions/google-meet/src/voice-call-gateway.ts +++ b/extensions/google-meet/src/voice-call-gateway.ts @@ -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; @@ -30,13 +31,6 @@ type VoiceCallMeetJoinResult = { introSent: boolean; }; -function sleep(ms: number): Promise { - if (ms <= 0) { - return Promise.resolve(); - } - return new Promise((resolve) => setTimeout(resolve, ms)); -} - async function createConnectedGatewayClient( config: GoogleMeetConfig, ): Promise { diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 15076025f76..3bcdebffd7f 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -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"), diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 961ead6ce5f..91f6bd2ce1b 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -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:*", diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index b08fa6e6311..68caa462f1b 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -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"; diff --git a/extensions/line/src/rich-menu.ts b/extensions/line/src/rich-menu.ts index 6876a3fec8d..a2117d5d996 100644 --- a/extensions/line/src/rich-menu.ts +++ b/extensions/line/src/rich-menu.ts @@ -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"; diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index d6e0b39e75e..c0ca363f5f5 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw JSON-only LLM task plugin", "type": "module", "dependencies": { - "ajv": "^8.20.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index b48079cbdaf..5f06774313c 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -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 }).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("../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: {}, diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 7ab86a71931..7f97d442c78 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -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 || ""} ${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}`); } } diff --git a/extensions/matrix/src/matrix/client/config.test.ts b/extensions/matrix/src/matrix/client/config.test.ts index 0ef663cd725..c2cc11e1972 100644 --- a/extensions/matrix/src/matrix/client/config.test.ts +++ b/extensions/matrix/src/matrix/client/config.test.ts @@ -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", () => { diff --git a/extensions/matrix/src/matrix/client/private-network-host.ts b/extensions/matrix/src/matrix/client/private-network-host.ts index 61fc5bf55a1..d180c2acb58 100644 --- a/extensions/matrix/src/matrix/client/private-network-host.ts +++ b/extensions/matrix/src/matrix/client/private-network-host.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 38a99cfc712..3faa8590911 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -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; diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index f8e8b7367ee..3c01f3a73c6 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export async function createMattermostPost( client: MattermostClient, params: { diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 432229c3572..080e3703846 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -475,6 +475,7 @@ describe("createMattermostInteractionHandler", () => { const listeners = new Map 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[]) => { diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 2735f621697..ecbd0874ead 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -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 { - 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, }); } diff --git a/extensions/mattermost/src/mattermost/monitor-slash.ts b/extensions/mattermost/src/mattermost/monitor-slash.ts index 16a933592b6..cfd1261b7e5 100644 --- a/extensions/mattermost/src/mattermost/monitor-slash.ts +++ b/extensions/mattermost/src/mattermost/monitor-slash.ts @@ -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; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 0bafa962d40..61494e8b226 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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)) diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts index 32d9d7356aa..71007e94f4d 100644 --- a/extensions/migrate-claude/helpers.ts +++ b/extensions/migrate-claude/helpers.ts @@ -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 { diff --git a/extensions/migrate-claude/provider.test.ts b/extensions/migrate-claude/provider.test.ts index 29ecc4d154a..a068506165b 100644 --- a/extensions/migrate-claude/provider.test.ts +++ b/extensions/migrate-claude/provider.test.ts @@ -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"); diff --git a/extensions/migrate-hermes/helpers.ts b/extensions/migrate-hermes/helpers.ts index 401f400d7d8..7192fb41adc 100644 --- a/extensions/migrate-hermes/helpers.ts +++ b/extensions/migrate-hermes/helpers.ts @@ -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 { diff --git a/extensions/migrate-hermes/provider.test.ts b/extensions/migrate-hermes/provider.test.ts index 5795202906a..1c881f326c4 100644 --- a/extensions/migrate-hermes/provider.test.ts +++ b/extensions/migrate-hermes/provider.test.ts @@ -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"); diff --git a/extensions/minimax/video-generation-provider.test.ts b/extensions/minimax/video-generation-provider.test.ts index 89b42f25918..fc6a5097eb9 100644 --- a/extensions/minimax/video-generation-provider.test.ts +++ b/extensions/minimax/video-generation-provider.test.ts @@ -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", diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index 11696affcf6..29ea054db20 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -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"}`, }; } diff --git a/extensions/msteams/src/file-consent.test.ts b/extensions/msteams/src/file-consent.test.ts index 0a3d641c524..ff6eca8bd7d 100644 --- a/extensions/msteams/src/file-consent.test.ts +++ b/extensions/msteams/src/file-consent.test.ts @@ -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) => { diff --git a/extensions/msteams/src/file-consent.ts b/extensions/msteams/src/file-consent.ts index 9829b5ae6fa..f87360c2128 100644 --- a/extensions/msteams/src/file-consent.ts +++ b/extensions/msteams/src/file-consent.ts @@ -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. diff --git a/extensions/msteams/src/polls.ts b/extensions/msteams/src/polls.ts index c9a4a73c6c5..0b0051aa8f5 100644 --- a/extensions/msteams/src/polls.ts +++ b/extensions/msteams/src/polls.ts @@ -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 { - 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(); diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 3b45fd47c0e..cf2def49a02 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -7,9 +7,6 @@ "url": "https://github.com/openclaw/openclaw" }, "type": "module", - "dependencies": { - "zod": "^4.4.3" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", "openclaw": "workspace:*" diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 468d8af6927..5d2a42d50b8 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -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 { diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 78037bcbb84..1488d29eea7 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -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:*", diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index 0e25aa3336d..0e75eeba12a 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -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; diff --git a/extensions/nostr/src/nostr-state-store.ts b/extensions/nostr/src/nostr-state-store.ts index 3285ffb7b63..e412879a2de 100644 --- a/extensions/nostr/src/nostr-state-store.ts +++ b/extensions/nostr/src/nostr-state-store.ts @@ -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; diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index 5e4ea0152b7..bcd08cb4be0 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -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" }), diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index 079c09f99c2..0cff725b38c 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -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}`; } diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index f76f71c1aa5..2335c89a396 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -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", diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index e228cca6703..4559e85be42 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -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"}`, }; } diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index 0a5722bf3a5..da110dab135 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -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 () => { diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index 59e996cfca9..906826d8da3 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -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(); diff --git a/extensions/perplexity/src/perplexity-web-search-provider.shared.ts b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts index b40a4c9df58..6206f3c05bf 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.shared.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts @@ -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; diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index d99c27c26f2..1c7ca79a6c8 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -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:*", diff --git a/extensions/qa-lab/src/browser-runtime.ts b/extensions/qa-lab/src/browser-runtime.ts index 5cc91897871..7f72d6039f1 100644 --- a/extensions/qa-lab/src/browser-runtime.ts +++ b/extensions/qa-lab/src/browser-runtime.ts @@ -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((resolve) => setTimeout(resolve, ms)); -} - export async function waitForQaBrowserReady( env: QaBrowserEnv, params: QaBrowserReadyParams = {}, diff --git a/extensions/qa-lab/src/bus-server.test.ts b/extensions/qa-lab/src/bus-server.test.ts index 56c89f6c208..43badbf786e 100644 --- a/extensions/qa-lab/src/bus-server.test.ts +++ b/extensions/qa-lab/src/bus-server.test.ts @@ -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): Promise { await new Promise((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" }); + }); +}); diff --git a/extensions/qa-lab/src/bus-server.ts b/extensions/qa-lab/src/bus-server.ts index 5db53d45206..0c79d45d661 100644 --- a/extensions/qa-lab/src/bus-server.ts +++ b/extensions/qa-lab/src/bus-server.ts @@ -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 { - 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 { + 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 { 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; - try { + const body = (await readQaJsonBody(params.req)) as Record; 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; } diff --git a/extensions/qa-lab/src/gateway-log-redaction.ts b/extensions/qa-lab/src/gateway-log-redaction.ts index b0c0e664bd9..65f9988baa8 100644 --- a/extensions/qa-lab/src/gateway-log-redaction.ts +++ b/extensions/qa-lab/src/gateway-log-redaction.ts @@ -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`, @@ -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`, diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 7dea2c896a3..0aa5c0e3c45 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -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 () => { diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index 6a5554463ee..fa50a250a93 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -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[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 { - 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[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); } }); diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index a8a521c64b5..7ec599efa1c 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -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 { diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts index 2f6d8ee983c..2148a6c6dc2 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts @@ -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, diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts index f12ec03956e..2ed2f918c93 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts @@ -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 { diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 5706088ddab..8e17e1e36b9 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -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 { diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts index c2f8aa4d3f9..86361c908b1 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts @@ -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 { diff --git a/extensions/qa-lab/src/multipass.runtime.ts b/extensions/qa-lab/src/multipass.runtime.ts index a7a94815024..95cb151f91e 100644 --- a/extensions/qa-lab/src/multipass.runtime.ts +++ b/extensions/qa-lab/src/multipass.runtime.ts @@ -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((resolve, reject) => { execFile( diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 6758b05d631..c775b3127c0 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -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; @@ -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 { - 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")); } diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts index b37150b04c7..23683c26c0b 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts @@ -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, diff --git a/extensions/qa-lab/src/qa-credentials-common.runtime.ts b/extensions/qa-lab/src/qa-credentials-common.runtime.ts index 5ad3a45e247..ec1868fc1b0 100644 --- a/extensions/qa-lab/src/qa-credentials-common.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-common.runtime.ts @@ -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.`, ); diff --git a/extensions/qa-lab/src/scenario-catalog.ts b/extensions/qa-lab/src/scenario-catalog.ts index 6e8b200a192..95417f6d1e9 100644 --- a/extensions/qa-lab/src/scenario-catalog.ts +++ b/extensions/qa-lab/src/scenario-catalog.ts @@ -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 diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 04e8133fcb6..d47895f7b3a 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -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:*", diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts index 28f7553b6b7..5e91d25a7c9 100644 --- a/extensions/qqbot/src/config-schema.ts +++ b/extensions/qqbot/src/config-schema.ts @@ -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({ diff --git a/extensions/qqbot/src/engine/messaging/media-type-detect.ts b/extensions/qqbot/src/engine/messaging/media-type-detect.ts index 87e5fe321a0..5373861e707 100644 --- a/extensions/qqbot/src/engine/messaging/media-type-detect.ts +++ b/extensions/qqbot/src/engine/messaging/media-type-detect.ts @@ -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) ?? ""); } diff --git a/extensions/qqbot/src/engine/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts index 42cee05d06a..1beae7ea44b 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.test.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.test.ts @@ -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; diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts index 36d2eb0dd51..287d68016c9 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -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 = { - ".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. */ diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 41c00fec148..db85e8a6caf 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -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; } diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index 154b4d38a99..796d61bad2e 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -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", diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index e127b288757..519c0a9e796 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -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 }, }); } diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index b1f20910cd7..51f9bdd6011 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -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) { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 5e607e4f71b..38d4e8034a0 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - // Slack reactions.add/remove expect shortcode names, not raw unicode emoji. const UNICODE_TO_SLACK: Record = { "👀": "eyes", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index c18ecf0e47e..26481d8345d 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -7,9 +7,6 @@ "url": "https://github.com/openclaw/openclaw" }, "type": "module", - "dependencies": { - "zod": "^4.4.3" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 3402a1867dc..d8ea3ce5c14 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -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({ channelKey: CHANNEL_ID, resolvePolicy: (account) => account.dmPolicy, diff --git a/extensions/synology-chat/src/client.ts b/extensions/synology-chat/src/client.ts index e963e522751..2f026dcf62e 100644 --- a/extensions/synology-chat/src/client.ts +++ b/extensions/synology-chat/src/client.ts @@ -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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index df5692cd6aa..1727d726119 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -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) ?? {}; } diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index ab7b1b82ee8..38132cd6812 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -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(); const invalidTokenRateLimiters = new Map(); diff --git a/extensions/tavily/src/tavily-tool-schema.ts b/extensions/tavily/src/tavily-tool-schema.ts index 14283b2e453..fc151460d14 100644 --- a/extensions/tavily/src/tavily-tool-schema.ts +++ b/extensions/tavily/src/tavily-tool-schema.ts @@ -1,14 +1 @@ -import { Type } from "typebox"; - -export function optionalStringEnum( - values: T, - options: { description?: string } = {}, -) { - return Type.Optional( - Type.Unsafe({ - type: "string", - enum: [...values], - ...options, - }), - ); -} +export { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions"; diff --git a/extensions/telegram/src/normalize.ts b/extensions/telegram/src/normalize.ts index 012e8e05ae5..65d6cafcb50 100644 --- a/extensions/telegram/src/normalize.ts +++ b/extensions/telegram/src/normalize.ts @@ -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) { diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index 2995b02d6e0..c60d18ef0b7 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -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) { diff --git a/extensions/tlon/src/monitor/approval.ts b/extensions/tlon/src/monitor/approval.ts index e1d5416d2f0..ed25892d903 100644 --- a/extensions/tlon/src/monitor/approval.ts +++ b/extensions/tlon/src/monitor/approval.ts @@ -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; diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index 9ac2cde155d..e7dbce239ae 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -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 = { - "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 { diff --git a/extensions/tlon/src/tlon-api.ts b/extensions/tlon/src/tlon-api.ts index 502b69be636..a149535cd33 100644 --- a/extensions/tlon/src/tlon-api.ts +++ b/extensions/tlon/src/tlon-api.ts @@ -1,8 +1,8 @@ import crypto from "node:crypto"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { authenticate } from "./urbit/auth.js"; import { scryUrbitPath } from "./urbit/channel-ops.js"; import { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./urbit/context.js"; @@ -44,16 +44,6 @@ type UploadResult = { const MEMEX_BASE_URL = "https://memex.tlon.network"; -const mimeToExt: Record = { - "image/gif": ".gif", - "image/heic": ".heic", - "image/heif": ".heif", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", -}; - let currentClientConfig: ClientConfig | null = null; export function configureClient(params: ClientConfig): void { @@ -71,10 +61,7 @@ function requireClientConfig(): ClientConfig { } function getExtensionFromMimeType(mimeType?: string): string { - if (!mimeType) { - return ".jpg"; - } - return mimeToExt[normalizeLowercaseStringOrEmpty(mimeType)] || ".jpg"; + return extensionForMime(mimeType) || ".jpg"; } function hasCustomS3Creds( diff --git a/extensions/together/video-generation-provider.test.ts b/extensions/together/video-generation-provider.test.ts index e75f8f4672d..27097c183a9 100644 --- a/extensions/together/video-generation-provider.test.ts +++ b/extensions/together/video-generation-provider.test.ts @@ -39,8 +39,8 @@ describe("together 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 = buildTogetherVideoGenerationProvider(); @@ -57,6 +57,7 @@ describe("together video generation provider", () => { }), ); expect(result.videos).toHaveLength(1); + expect(result.videos[0]?.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ videoId: "video_123", diff --git a/extensions/together/video-generation-provider.ts b/extensions/together/video-generation-provider.ts index 856c39935d6..dfddedd759b 100644 --- a/extensions/together/video-generation-provider.ts +++ b/extensions/together/video-generation-provider.ts @@ -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 { @@ -113,7 +114,7 @@ async function downloadTogetherVideo(params: { return { buffer: Buffer.from(arrayBuffer), mimeType, - fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`, + fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`, }; } diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index 6d52ee7b1fa..0d317428dc1 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -1,13 +1,10 @@ import { randomUUID } from "node:crypto"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; /** * Twitch-specific utility functions */ -function normalizeLowercaseStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - /** * Normalize Twitch channel names. * diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index ec0d311787f..9484f1b8dfd 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; import { consultRealtimeVoiceAgent, REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, @@ -187,17 +188,10 @@ function createRuntimeResourceLifecycle(params: { }; } -function isLoopbackBind(bind: string | undefined): boolean { - if (!bind) { - return false; - } - return bind === "127.0.0.1" || bind === "::1" || bind === "localhost"; -} - async function resolveProvider(config: VoiceCallConfig): Promise { const allowNgrokFreeTierLoopbackBypass = config.tunnel?.provider === "ngrok" && - isLoopbackBind(config.serve?.bind) && + isLoopbackHost(config.serve?.bind ?? "") && (config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false); switch (config.provider) { diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 45e2d39b009..847f5f499ef 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { getHeader } from "./http-headers.js"; @@ -360,19 +361,6 @@ function buildTwilioVerificationUrl( } } -function isLoopbackAddress(address?: string): boolean { - if (!address) { - return false; - } - if (address === "127.0.0.1" || address === "::1") { - return true; - } - if (address.startsWith("::ffff:127.")) { - return true; - } - return false; -} - function stripPortFromUrl(url: string): string { try { const parsed = new URL(url); @@ -614,7 +602,7 @@ export function verifyTwilioWebhook( return { ok: false, reason: "Missing X-Twilio-Signature header" }; } - const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress); + const isLoopback = isLoopbackHost(options?.remoteIP ?? ctx.remoteAddress ?? ""); const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback; // Reconstruct the URL Twilio used diff --git a/extensions/vydra/shared.ts b/extensions/vydra/shared.ts index f6d2046e570..0b89b1ac661 100644 --- a/extensions/vydra/shared.ts +++ b/extensions/vydra/shared.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, @@ -9,7 +10,6 @@ import { waitProviderOperationPollInterval, } from "openclaw/plugin-sdk/provider-http"; import { - normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; @@ -193,27 +193,11 @@ export function extractVydraResultUrls(payload: unknown, kind: VydraMediaKind): return [...urls]; } -function inferExtension(kind: VydraMediaKind, mimeType: string): string { - const normalized = normalizeLowercaseStringOrEmpty(mimeType); - if (normalized.includes("jpeg")) { - return "jpg"; - } - if (normalized.includes("webp")) { - return "webp"; - } - if (normalized.includes("wav")) { - return "wav"; - } - if (normalized.includes("mpeg") || normalized.includes("mp3")) { - return "mp3"; - } - if (normalized.includes("webm")) { - return "webm"; - } - if (normalized.includes("quicktime")) { - return "mov"; - } - return kind === "image" ? "png" : kind === "audio" ? "mp3" : "mp4"; +function resolveVydraFileExtension(kind: VydraMediaKind, mimeType: string): string { + return ( + extensionForMime(mimeType)?.slice(1) ?? + (kind === "image" ? "png" : kind === "audio" ? "mp3" : "mp4") + ); } export async function downloadVydraAsset(params: { @@ -233,7 +217,7 @@ export async function downloadVydraAsset(params: { response.headers.get("content-type")?.trim() || (params.kind === "image" ? "image/png" : params.kind === "audio" ? "audio/mpeg" : "video/mp4"); const arrayBuffer = await response.arrayBuffer(); - const extension = inferExtension(params.kind, mimeType); + const extension = resolveVydraFileExtension(params.kind, mimeType); const fileStem = params.kind === "image" ? "image" : params.kind === "audio" ? "audio" : "video"; return { buffer: Buffer.from(arrayBuffer), diff --git a/extensions/vydra/video-generation-provider.test.ts b/extensions/vydra/video-generation-provider.test.ts index 78cb25d96e3..f1dd7bb83ed 100644 --- a/extensions/vydra/video-generation-provider.test.ts +++ b/extensions/vydra/video-generation-provider.test.ts @@ -30,7 +30,7 @@ describe("vydra video-generation provider", () => { status: "completed", videoUrl: "https://cdn.vydra.ai/generated/test.mp4", }), - binaryResponse("mp4-data", "video/mp4"), + binaryResponse("webm-data", "video/webm"), ); const provider = buildVydraVideoGenerationProvider(); @@ -54,7 +54,8 @@ describe("vydra video-generation provider", () => { "https://www.vydra.ai/api/v1/jobs/job-123", expect.objectContaining({ method: "GET" }), ); - 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({ jobId: "job-123", videoUrl: "https://cdn.vydra.ai/generated/test.mp4", diff --git a/extensions/webhooks/package.json b/extensions/webhooks/package.json index eb08600b837..cc3f269ad62 100644 --- a/extensions/webhooks/package.json +++ b/extensions/webhooks/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw webhook bridge plugin", "type": "module", - "dependencies": { - "zod": "^4.4.3" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/webhooks/src/config.ts b/extensions/webhooks/src/config.ts index 0d138853999..4c15e288397 100644 --- a/extensions/webhooks/src/config.ts +++ b/extensions/webhooks/src/config.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "openclaw/plugin-sdk/zod"; import { normalizeWebhookPath } from "../runtime-api.js"; const secretRefSchema = z diff --git a/extensions/webhooks/src/http.ts b/extensions/webhooks/src/http.ts index 5f2d136cae5..75d35334564 100644 --- a/extensions/webhooks/src/http.ts +++ b/extensions/webhooks/src/http.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { z } from "zod"; +import { z } from "openclaw/plugin-sdk/zod"; import type { PluginRuntime } from "../api.js"; import { createFixedWindowRateLimiter, diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts index 66e0e1f4bfa..af4e9055ebb 100644 --- a/extensions/xai/video-generation-provider.test.ts +++ b/extensions/xai/video-generation-provider.test.ts @@ -38,8 +38,8 @@ describe("xai 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 = buildXaiVideoGenerationProvider(); @@ -72,7 +72,8 @@ describe("xai video generation provider", () => { 120000, fetch, ); - 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({ requestId: "req_123", diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 35724e4bcb1..8fd2bff2976 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -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 { @@ -307,7 +308,7 @@ async function downloadXaiVideo(params: { return { buffer: Buffer.from(arrayBuffer), mimeType, - fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`, + fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`, }; } diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 1801cbb0a16..2246313eaab 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -7,9 +7,6 @@ "url": "https://github.com/openclaw/openclaw" }, "type": "module", - "dependencies": { - "undici": "8.2.0" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", "openclaw": "workspace:*" diff --git a/extensions/zalo/src/proxy.ts b/extensions/zalo/src/proxy.ts index ea47b59e109..266ea987481 100644 --- a/extensions/zalo/src/proxy.ts +++ b/extensions/zalo/src/proxy.ts @@ -1,5 +1,4 @@ -import type { RequestInit as UndiciRequestInit } from "undici"; -import { ProxyAgent, fetch as undiciFetch } from "undici"; +import { makeProxyFetch } from "openclaw/plugin-sdk/fetch-runtime"; import type { ZaloFetch } from "./api.js"; const proxyCache = new Map(); @@ -13,12 +12,7 @@ export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | und if (cached) { return cached; } - const agent = new ProxyAgent(trimmed); - const fetcher: ZaloFetch = (input, init) => - undiciFetch(input, { - ...init, - dispatcher: agent, - } as UndiciRequestInit) as unknown as Promise; + const fetcher = makeProxyFetch(trimmed) as ZaloFetch; proxyCache.set(trimmed, fetcher); return fetcher; } diff --git a/extensions/zalouser/src/tool.ts b/extensions/zalouser/src/tool.ts index cdf35bd3e53..4d9af06fe82 100644 --- a/extensions/zalouser/src/tool.ts +++ b/extensions/zalouser/src/tool.ts @@ -1,3 +1,4 @@ +import { stringEnum } from "openclaw/plugin-sdk/channel-actions"; import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { Type } from "typebox"; @@ -17,17 +18,6 @@ type AgentToolResult = { details: unknown; }; -function stringEnum( - values: T, - options: { description?: string } = {}, -) { - return Type.Unsafe({ - type: "string", - enum: [...values], - ...options, - }); -} - const ZalouserToolSchema = Type.Object( { action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }), diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 3859e8b66dc..f460d095ac1 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; import { privateFileStoreSync, @@ -14,6 +15,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, + sleep, } from "openclaw/plugin-sdk/text-runtime"; import { normalizeZaloReactionIcon } from "./reaction.js"; import { createZalouserSendReceipt } from "./send-receipt.js"; @@ -139,10 +141,6 @@ function writeCredentialFileAtomic(filePath: string, payload: string): void { privateFileStoreSync(resolveCredentialsDir()).writeText(path.basename(filePath), payload); } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function normalizeProfile(profile?: string | null): string { const trimmed = profile?.trim(); return trimmed && trimmed.length > 0 ? trimmed : "default"; @@ -477,27 +475,14 @@ function resolveMediaFileName(params: { } const ext = - params.contentType === "image/png" - ? "png" - : params.contentType === "image/webp" - ? "webp" - : params.contentType === "image/jpeg" + extensionForMime(params.contentType)?.replace(/^\./u, "") ?? + (params.kind === "video" + ? "mp4" + : params.kind === "audio" + ? "mp3" + : params.kind === "image" ? "jpg" - : params.contentType === "video/mp4" - ? "mp4" - : params.contentType === "audio/mpeg" - ? "mp3" - : params.contentType === "audio/ogg" - ? "ogg" - : params.contentType === "audio/wav" - ? "wav" - : params.kind === "video" - ? "mp4" - : params.kind === "audio" - ? "mp3" - : params.kind === "image" - ? "jpg" - : "bin"; + : "bin"); return `upload.${ext}`; } @@ -1624,7 +1609,7 @@ export async function startZaloQrLogin(params: { message: "Scan this QR with the Zalo app.", }; } - await delay(150); + await sleep(150); } return { @@ -1674,7 +1659,7 @@ export async function waitForZaloQrLogin(params: { message: "Login successful.", }; } - await Promise.race([active.waitPromise, delay(400)]); + await Promise.race([active.waitPromise, sleep(400)]); } return { diff --git a/package.json b/package.json index 28116b02a73..d44e5cf8f7b 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,10 @@ "types": "./dist/plugin-sdk/config-schema.d.ts", "default": "./dist/plugin-sdk/config-schema.js" }, + "./plugin-sdk/json-schema-runtime": { + "types": "./dist/plugin-sdk/json-schema-runtime.d.ts", + "default": "./dist/plugin-sdk/json-schema-runtime.js" + }, "./plugin-sdk/reply-runtime": { "types": "./dist/plugin-sdk/reply-runtime.d.ts", "default": "./dist/plugin-sdk/reply-runtime.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76b1385ce0..0fee5302b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,9 +504,6 @@ importers: ws: specifier: ^8.20.0 version: 8.20.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -774,9 +771,6 @@ importers: google-auth-library: specifier: 10.6.2 version: 10.6.2 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -864,9 +858,6 @@ importers: extensions/llm-task: dependencies: - ajv: - specifier: ^8.20.0 - version: 8.20.0 typebox: specifier: 1.1.37 version: 1.1.37 @@ -1096,10 +1087,6 @@ importers: version: link:../.. extensions/nextcloud-talk: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1113,9 +1100,6 @@ importers: nostr-tools: specifier: ^2.23.3 version: 2.23.3(typescript@6.0.3) - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1223,9 +1207,6 @@ importers: yaml: specifier: ^2.8.4 version: 2.8.4 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/discord': specifier: workspace:* @@ -1279,9 +1260,6 @@ importers: ws: specifier: ^8.20.0 version: 8.20.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1371,10 +1349,6 @@ importers: version: link:../../packages/plugin-sdk extensions/synology-chat: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1559,10 +1533,6 @@ importers: version: link:../../packages/plugin-sdk extensions/webhooks: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1622,10 +1592,6 @@ importers: version: link:../../packages/plugin-sdk extensions/zalo: - dependencies: - undici: - specifier: 8.2.0 - version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a8f0cd0868e..dba769f808c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -35,6 +35,7 @@ "config-mutation", "cron-store-runtime", "config-schema", + "json-schema-runtime", "reply-runtime", "reply-dedupe", "reply-dispatch-runtime", diff --git a/src/agents/schema/string-enum.ts b/src/agents/schema/string-enum.ts index 56fee72de66..e031f8e6f0d 100644 --- a/src/agents/schema/string-enum.ts +++ b/src/agents/schema/string-enum.ts @@ -4,6 +4,7 @@ type StringEnumOptions = { description?: string; title?: string; default?: T[number]; + deprecated?: boolean; }; // Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf. diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index 91430361194..c6b852ff035 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -8,6 +8,7 @@ import { imageMimeFromFormat, isAudioFileName, kindFromMime, + mimeTypeFromFilePath, normalizeMimeType, sliceMimeSniffBuffer, } from "./mime.js"; @@ -144,6 +145,26 @@ describe("mime detection", () => { }); }); +describe("mimeTypeFromFilePath", () => { + it.each([ + { filePath: "image.bmp", expected: "image/bmp" }, + { filePath: "photo.jpg", expected: "image/jpeg" }, + { filePath: "photo.JPG", expected: "image/jpeg" }, + { filePath: "voice.mp3", expected: "audio/mpeg" }, + { filePath: "voice.wav", expected: "audio/wav" }, + { filePath: "clip.avi", expected: "video/x-msvideo" }, + { filePath: "clip.mkv", expected: "video/x-matroska" }, + { filePath: "clip.webm", expected: "video/webm" }, + { filePath: "clip.flv", expected: "video/x-flv" }, + { filePath: "clip.wmv", expected: "video/x-ms-wmv" }, + { filePath: "debug.log", expected: "text/plain" }, + { filePath: "page.xml", expected: "text/xml" }, + { filePath: "unknown.bin", expected: undefined }, + ] as const)("maps $filePath", ({ filePath, expected }) => { + expect(mimeTypeFromFilePath(filePath)).toBe(expected); + }); +}); + describe("extensionForMime", () => { function expectMimeExtensionCase( mime: Parameters[0], @@ -154,15 +175,26 @@ describe("extensionForMime", () => { it.each([ { mime: "image/jpeg", expected: ".jpg" }, + { mime: "image/jpg", expected: ".jpg" }, + { mime: "image/bmp", expected: ".bmp" }, { mime: "image/png", expected: ".png" }, + { mime: "image/svg+xml", expected: ".svg" }, { mime: "image/webp", expected: ".webp" }, { mime: "image/gif", expected: ".gif" }, { mime: "image/heic", expected: ".heic" }, { mime: "audio/mpeg", expected: ".mp3" }, + { mime: "audio/mp3", expected: ".mp3" }, { mime: "audio/ogg", expected: ".ogg" }, + { mime: "audio/x-wav", expected: ".wav" }, + { mime: "audio/webm", expected: ".webm" }, { mime: "audio/x-m4a", expected: ".m4a" }, { mime: "audio/mp4", expected: ".m4a" }, + { mime: "video/x-msvideo", expected: ".avi" }, { mime: "video/mp4", expected: ".mp4" }, + { mime: "video/x-matroska", expected: ".mkv" }, + { mime: "video/webm", expected: ".webm" }, + { mime: "video/x-flv", expected: ".flv" }, + { mime: "video/x-ms-wmv", expected: ".wmv" }, { mime: "video/quicktime", expected: ".mov" }, { mime: "application/pdf", expected: ".pdf" }, { mime: "text/plain", expected: ".txt" }, diff --git a/src/media/mime.ts b/src/media/mime.ts index 65acbd91733..99c3ad15677 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -9,20 +9,32 @@ export const FILE_TYPE_SNIFF_MAX_BYTES = 1024 * 1024; const EXT_BY_MIME: Record = { "image/heic": ".heic", "image/heif": ".heif", + "image/bmp": ".bmp", + "image/jpg": ".jpg", "image/jpeg": ".jpg", "image/png": ".png", + "image/svg+xml": ".svg", "image/webp": ".webp", "image/gif": ".gif", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", "audio/wav": ".wav", + "audio/wave": ".wav", + "audio/x-wav": ".wav", "audio/flac": ".flac", "audio/aac": ".aac", "audio/opus": ".opus", + "audio/webm": ".webm", "audio/x-m4a": ".m4a", "audio/mp4": ".m4a", "audio/x-caf": ".caf", + "video/x-msvideo": ".avi", "video/mp4": ".mp4", + "video/x-matroska": ".mkv", + "video/webm": ".webm", + "video/x-flv": ".flv", + "video/x-ms-wmv": ".wmv", "video/quicktime": ".mov", "application/pdf": ".pdf", "application/json": ".json", @@ -46,11 +58,25 @@ const EXT_BY_MIME: Record = { "application/xml": ".xml", }; +function buildMimeByExt(): Record { + const byExt: Record = {}; + for (const [mime, ext] of Object.entries(EXT_BY_MIME)) { + byExt[ext] ??= mime; + } + return byExt; +} + const MIME_BY_EXT: Record = { - ...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])), + ...buildMimeByExt(), + // Canonical extension mappings for common MIME aliases + ".jpg": "image/jpeg", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".webm": "video/webm", // Additional extension aliases ".jpeg": "image/jpeg", ".js": "text/javascript", + ".log": "text/plain", ".htm": "text/html", ".xml": "text/xml", }; diff --git a/src/plugin-sdk/json-schema-runtime.ts b/src/plugin-sdk/json-schema-runtime.ts new file mode 100644 index 00000000000..85e699b8a44 --- /dev/null +++ b/src/plugin-sdk/json-schema-runtime.ts @@ -0,0 +1,4 @@ +// Narrow JSON Schema validator surface for plugins that validate tool/model output. + +export { validateJsonSchemaValue } from "../plugins/schema-validator.js"; +export type { JsonSchemaObject } from "../shared/json-schema.types.js"; diff --git a/src/plugin-sdk/media-mime.ts b/src/plugin-sdk/media-mime.ts index 9944711e944..b17fc67f8d5 100644 --- a/src/plugin-sdk/media-mime.ts +++ b/src/plugin-sdk/media-mime.ts @@ -4,6 +4,7 @@ export { detectMime, extensionForMime, getFileExtension, + mimeTypeFromFilePath, normalizeMimeType, } from "../media/mime.js"; export { mediaKindFromMime, type MediaKind } from "../media/constants.js"; diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index 2bae8c8e844..0d3c3a32b3b 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -146,6 +146,36 @@ describe("schema validator", () => { expectValidationIssue(result, ""); }); + it("can isolate caller schemas that reuse the same $id with different shapes", () => { + const first = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.same-id.uncached", + schema: { + $id: "https://example.test/shared-schema", + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + additionalProperties: false, + }, + value: { foo: "ok" }, + cache: false, + }); + expect(first.ok).toBe(true); + + const second = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.same-id.uncached", + schema: { + $id: "https://example.test/shared-schema", + type: "object", + properties: { bar: { type: "number" } }, + required: ["bar"], + additionalProperties: false, + }, + value: { bar: 1 }, + cache: false, + }); + expect(second.ok).toBe(true); + }); + it.each([ { title: "includes allowed values in enum validation errors", diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index 11fc17f9b80..9fc18345297 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -20,11 +20,7 @@ type AjvLike = { }; const ajvSingletons = new Map<"default" | "defaults", AjvLike>(); -function getAjv(mode: "default" | "defaults"): AjvLike { - const cached = ajvSingletons.get(mode); - if (cached) { - return cached; - } +function createAjv(mode: "default" | "defaults"): AjvLike { const ajvModule = require("ajv") as { default?: new (opts?: object) => AjvLike }; const AjvCtor = typeof ajvModule.default === "function" @@ -44,6 +40,15 @@ function getAjv(mode: "default" | "defaults"): AjvLike { return URL.canParse(value); }, }); + return instance; +} + +function getAjv(mode: "default" | "defaults"): AjvLike { + const cached = ajvSingletons.get(mode); + if (cached) { + return cached; + } + const instance = createAjv(mode); ajvSingletons.set(mode, instance); return instance; } @@ -197,7 +202,24 @@ export function validateJsonSchemaValue(params: { cacheKey: string; value: unknown; applyDefaults?: boolean; + cache?: boolean; }): { ok: true; value: unknown } | { ok: false; errors: JsonSchemaValidationError[] } { + const useCache = params.cache !== false; + if (!useCache) { + const validate = createAjv(params.applyDefaults ? "defaults" : "default").compile( + params.schema, + ); + const value = + params.applyDefaults && schemaHasDefaults(params.schema) + ? cloneValidationValue(params.value) + : params.value; + const ok = validate(value); + if (ok) { + return { ok: true, value }; + } + return { ok: false, errors: formatAjvErrors(validate.errors) }; + } + const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey; let cached = schemaCache.get(cacheKey); const schemaFingerprint =