mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:30:43 +00:00
fix: share plugin runtime helpers
Consolidate shared plugin runtime MIME/schema helpers, preserve canonical runtime behavior, and guard QQBot STT fetches.
This commit is contained in:
committed by
GitHub
parent
f3c9203631
commit
6a4069dead
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import {
|
||||
isProviderApiKeyConfigured,
|
||||
type AuthProfileStore,
|
||||
@@ -304,25 +305,10 @@ async function readJsonResponse<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function inferFileExtension(params: { fileName?: string; mimeType?: string }): string {
|
||||
const normalizedMime = normalizeOptionalLowercaseString(params.mimeType);
|
||||
if (normalizedMime?.includes("jpeg")) {
|
||||
return "jpg";
|
||||
}
|
||||
if (normalizedMime?.includes("png")) {
|
||||
return "png";
|
||||
}
|
||||
if (normalizedMime?.includes("webm")) {
|
||||
return "webm";
|
||||
}
|
||||
if (normalizedMime?.includes("mp4")) {
|
||||
return "mp4";
|
||||
}
|
||||
if (normalizedMime?.includes("mpeg")) {
|
||||
return "mp3";
|
||||
}
|
||||
if (normalizedMime?.includes("wav")) {
|
||||
return "wav";
|
||||
function resolveFileExtension(params: { fileName?: string; mimeType?: string }): string {
|
||||
const extension = extensionForMime(params.mimeType);
|
||||
if (extension) {
|
||||
return extension.slice(1);
|
||||
}
|
||||
const fileName = params.fileName?.trim();
|
||||
if (!fileName) {
|
||||
@@ -356,7 +342,7 @@ async function uploadInputImage(params: {
|
||||
"image",
|
||||
new Blob([toBlobBytes(params.image.buffer)], { type: params.image.mimeType }),
|
||||
normalizeOptionalString(params.image.fileName) ||
|
||||
`input.${inferFileExtension({ mimeType: params.image.mimeType })}`,
|
||||
`input.${resolveFileExtension({ mimeType: params.image.mimeType })}`,
|
||||
);
|
||||
form.set("type", "input");
|
||||
form.set("overwrite", "true");
|
||||
@@ -823,7 +809,7 @@ export async function runComfyWorkflow(params: {
|
||||
mimeType: downloaded.mimeType,
|
||||
fileName:
|
||||
originalName ||
|
||||
`${params.capability}-${assetIndex}.${inferFileExtension({ mimeType: downloaded.mimeType })}`,
|
||||
`${params.capability}-${assetIndex}.${resolveFileExtension({ mimeType: downloaded.mimeType })}`,
|
||||
nodeId: output.nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { resolveLanguage } from "@pierre/diffs";
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { DiffViewerPayload } from "./types.js";
|
||||
|
||||
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
|
||||
type DiffPayloadFile = FileContents | FileDiffMetadata;
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export async function normalizeSupportedLanguageHint(
|
||||
value?: string,
|
||||
): Promise<SupportedLanguages | undefined> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { Static, Type } from "typebox";
|
||||
@@ -34,19 +35,6 @@ const MAX_TITLE_BYTES = 1_024;
|
||||
const MAX_PATH_BYTES = 2_048;
|
||||
const MAX_LANG_BYTES = 128;
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
description: string,
|
||||
options: { deprecated?: boolean } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
description,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
const DiffsToolSchema = Type.Object(
|
||||
{
|
||||
before: Type.Optional(Type.String({ description: "Original text content." })),
|
||||
@@ -76,17 +64,23 @@ const DiffsToolSchema = Type.Object(
|
||||
}),
|
||||
),
|
||||
mode: Type.Optional(
|
||||
stringEnum(
|
||||
DIFF_MODES,
|
||||
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
|
||||
),
|
||||
stringEnum(DIFF_MODES, {
|
||||
description:
|
||||
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
|
||||
}),
|
||||
),
|
||||
theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
|
||||
layout: Type.Optional(
|
||||
stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }),
|
||||
),
|
||||
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
|
||||
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
|
||||
fileQuality: Type.Optional(
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print."),
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
|
||||
description: "File quality preset: standard, hq, or print.",
|
||||
}),
|
||||
),
|
||||
fileFormat: Type.Optional(
|
||||
stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }),
|
||||
),
|
||||
fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
|
||||
fileScale: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Optional rendered-file device scale factor override (1-4).",
|
||||
@@ -103,13 +97,15 @@ const DiffsToolSchema = Type.Object(
|
||||
),
|
||||
/** @deprecated Use fileQuality. */
|
||||
imageQuality: Type.Optional(
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality.", {
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
|
||||
description: "Deprecated alias for fileQuality.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
/** @deprecated Use fileFormat. */
|
||||
imageFormat: Type.Optional(
|
||||
stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.", {
|
||||
stringEnum(DIFF_OUTPUT_FORMATS, {
|
||||
description: "Deprecated alias for fileFormat.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("fal video generation provider", () => {
|
||||
responseUrl: string;
|
||||
videoUrl: string;
|
||||
bytes: string;
|
||||
contentType?: string;
|
||||
responseExtras?: Record<string, unknown>;
|
||||
}) {
|
||||
fetchGuardMock
|
||||
@@ -78,7 +79,9 @@ describe("fal video generation provider", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: params.bytes }));
|
||||
.mockResolvedValueOnce(
|
||||
releasedVideo({ contentType: params.contentType ?? "video/mp4", bytes: params.bytes }),
|
||||
);
|
||||
}
|
||||
|
||||
function getSubmitBody(): Record<string, unknown> {
|
||||
@@ -119,7 +122,8 @@ describe("fal video generation provider", () => {
|
||||
statusUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status",
|
||||
responseUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123",
|
||||
videoUrl: "https://fal.run/files/video.mp4",
|
||||
bytes: "mp4-bytes",
|
||||
bytes: "webm-bytes",
|
||||
contentType: "video/webm",
|
||||
});
|
||||
|
||||
const provider = buildFalVideoGenerationProvider();
|
||||
@@ -158,7 +162,8 @@ describe("fal video generation provider", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.videos).toHaveLength(1);
|
||||
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
||||
expect(result.videos[0]?.mimeType).toBe("video/webm");
|
||||
expect(result.videos[0]?.fileName).toBe("video-1.webm");
|
||||
expect(result.videos[0]?.url).toBe("https://fal.run/files/video.mp4");
|
||||
expect(result.metadata).toEqual({
|
||||
requestId: "req-123",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { detectMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import {
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForRead,
|
||||
root,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { EXTENSION_MIME } from "../shared/mime.js";
|
||||
|
||||
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
||||
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const TEXT_SNIFF_MAX_BYTES = 8192;
|
||||
|
||||
type FileFetchParams = {
|
||||
path?: unknown;
|
||||
@@ -47,25 +47,6 @@ type FileFetchErr = {
|
||||
|
||||
type FileFetchResult = FileFetchOk | FileFetchErr;
|
||||
|
||||
function detectMimeType(filePath: string): string {
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("file", ["-b", "--mime-type", filePath], {
|
||||
encoding: "utf-8",
|
||||
timeout: 2000,
|
||||
});
|
||||
const stdout = result.stdout?.trim();
|
||||
if (result.status === 0 && stdout) {
|
||||
return stdout;
|
||||
}
|
||||
} catch {
|
||||
// fall through to extension fallback
|
||||
}
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return EXTENSION_MIME[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function clampMaxBytes(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
||||
return FILE_FETCH_DEFAULT_MAX_BYTES;
|
||||
@@ -101,6 +82,39 @@ function classifyFsError(err: unknown): FileFetchErrCode {
|
||||
return "READ_ERROR";
|
||||
}
|
||||
|
||||
function isLikelyPlainText(buffer: Buffer): boolean {
|
||||
if (buffer.byteLength === 0) {
|
||||
return true;
|
||||
}
|
||||
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
|
||||
if (sample.includes(0)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(sample);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let controlBytes = 0;
|
||||
for (const byte of sample) {
|
||||
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
||||
controlBytes += 1;
|
||||
}
|
||||
}
|
||||
return controlBytes / sample.byteLength < 0.01;
|
||||
}
|
||||
|
||||
async function detectFetchedFileMime(params: {
|
||||
buffer: Buffer;
|
||||
filePath: string;
|
||||
}): Promise<string> {
|
||||
const detected = await detectMime(params);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
|
||||
}
|
||||
|
||||
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
|
||||
const requestedPath = params.path;
|
||||
if (typeof requestedPath !== "string" || requestedPath.length === 0) {
|
||||
@@ -196,7 +210,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const base64 = buffer.toString("base64");
|
||||
const mimeType = detectMimeType(opened.realPath);
|
||||
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXTENSION_MIME,
|
||||
IMAGE_MIME_INLINE_SET,
|
||||
TEXT_INLINE_MAX_BYTES,
|
||||
TEXT_INLINE_MIME_SET,
|
||||
@@ -13,6 +12,8 @@ describe("mimeFromExtension", () => {
|
||||
expect(mimeFromExtension("/abs/path/bar.JPG")).toBe("image/jpeg");
|
||||
expect(mimeFromExtension("doc.pdf")).toBe("application/pdf");
|
||||
expect(mimeFromExtension("notes.md")).toBe("text/markdown");
|
||||
expect(mimeFromExtension("trace.log")).toBe("text/plain");
|
||||
expect(mimeFromExtension("bitmap.bmp")).toBe("image/bmp");
|
||||
});
|
||||
|
||||
it("falls back to application/octet-stream for unknown extensions", () => {
|
||||
@@ -28,11 +29,11 @@ describe("mimeFromExtension", () => {
|
||||
|
||||
describe("MIME constants", () => {
|
||||
it("EXTENSION_MIME includes the v1 image set", () => {
|
||||
expect(EXTENSION_MIME[".png"]).toBe("image/png");
|
||||
expect(EXTENSION_MIME[".jpg"]).toBe("image/jpeg");
|
||||
expect(EXTENSION_MIME[".jpeg"]).toBe("image/jpeg");
|
||||
expect(EXTENSION_MIME[".webp"]).toBe("image/webp");
|
||||
expect(EXTENSION_MIME[".gif"]).toBe("image/gif");
|
||||
expect(mimeFromExtension("image.png")).toBe("image/png");
|
||||
expect(mimeFromExtension("image.jpg")).toBe("image/jpeg");
|
||||
expect(mimeFromExtension("image.jpeg")).toBe("image/jpeg");
|
||||
expect(mimeFromExtension("image.webp")).toBe("image/webp");
|
||||
expect(mimeFromExtension("image.gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("IMAGE_MIME_INLINE_SET is the inline-renderable image set", () => {
|
||||
@@ -50,6 +51,7 @@ describe("MIME constants", () => {
|
||||
expect(TEXT_INLINE_MIME_SET.has("text/markdown")).toBe(true);
|
||||
expect(TEXT_INLINE_MIME_SET.has("application/json")).toBe(true);
|
||||
expect(TEXT_INLINE_MIME_SET.has("text/csv")).toBe(true);
|
||||
expect(TEXT_INLINE_MIME_SET.has("text/xml")).toBe(true);
|
||||
});
|
||||
|
||||
it("TEXT_INLINE_MAX_BYTES is the documented 8KB cap", () => {
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
import path from "node:path";
|
||||
|
||||
// Single source of truth for extension→MIME mapping. Used by all four
|
||||
// handlers/tools so adding a new extension lands everywhere at once.
|
||||
export const EXTENSION_MIME: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".log": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
".csv": "text/csv",
|
||||
".html": "text/html",
|
||||
".xml": "application/xml",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
};
|
||||
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
|
||||
|
||||
// MIME types we treat as inline-displayable images for vision-capable models.
|
||||
// Note: heic/heif are detectable but not all providers can render them, so we
|
||||
@@ -43,11 +19,11 @@ export const TEXT_INLINE_MIME_SET = new Set([
|
||||
"text/html",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
]);
|
||||
|
||||
export const TEXT_INLINE_MAX_BYTES = 8 * 1024;
|
||||
|
||||
export function mimeFromExtension(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return EXTENSION_MIME[ext] ?? "application/octet-stream";
|
||||
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
jsonResult,
|
||||
@@ -7,19 +8,6 @@ import {
|
||||
import { Type } from "typebox";
|
||||
import { runFirecrawlScrape } from "./firecrawl-client.js";
|
||||
|
||||
function optionalStringEnum<const T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const FirecrawlScrapeToolSchema = Type.Object(
|
||||
{
|
||||
url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type {
|
||||
GoogleMeetConfig,
|
||||
@@ -127,10 +128,6 @@ function resolveProbeTimeoutMs(input: number | undefined, fallback: number): num
|
||||
return Math.min(Math.trunc(input), 120_000);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isManagedChromeBrowserSession(session: GoogleMeetSession): boolean {
|
||||
return Boolean(
|
||||
(session.transport === "chrome" || session.transport === "chrome-node") &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { GoogleMeetConfig } from "../config.js";
|
||||
import {
|
||||
asBrowserTabs,
|
||||
@@ -71,10 +72,6 @@ export function isGoogleMeetBrowserManualActionError(
|
||||
return error instanceof GoogleMeetBrowserManualActionError;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function formatBrowserAutomationError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
startGatewayClientWhenEventLoopReady,
|
||||
} from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { GoogleMeetConfig } from "./config.js";
|
||||
|
||||
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
|
||||
@@ -30,13 +31,6 @@ type VoiceCallMeetJoinResult = {
|
||||
introSent: boolean;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function createConnectedGatewayClient(
|
||||
config: GoogleMeetConfig,
|
||||
): Promise<VoiceCallGatewayClient> {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"typebox": "1.1.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("ajv", () => ({
|
||||
default: class MockAjv {
|
||||
compile(schema: unknown) {
|
||||
return (value: unknown) => {
|
||||
if (
|
||||
schema &&
|
||||
typeof schema === "object" &&
|
||||
!Array.isArray(schema) &&
|
||||
(schema as { properties?: Record<string, { type?: string }> }).properties?.foo?.type ===
|
||||
"string"
|
||||
) {
|
||||
const ok = typeof (value as { foo?: unknown })?.foo === "string";
|
||||
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = ok
|
||||
? undefined
|
||||
: [{ instancePath: "/foo", message: "must be string" }];
|
||||
return ok;
|
||||
}
|
||||
(this as { errors?: Array<{ instancePath: string; message: string }> }).errors = undefined;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
errors?: Array<{ instancePath: string; message: string }>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../api.js")>("../api.js");
|
||||
return {
|
||||
@@ -35,7 +9,6 @@ vi.mock("../api.js", async () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("ajv");
|
||||
vi.doUnmock("../api.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -159,6 +132,45 @@ describe("llm-task tool (json-only)", () => {
|
||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("validates caller schemas with repeated $id independently across calls", async () => {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
(runEmbeddedPiAgent as any)
|
||||
.mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ count: 1 }) }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("id", {
|
||||
prompt: "return foo",
|
||||
schema: {
|
||||
$id: "https://example.test/llm-task-result",
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
required: ["foo"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ details: { json: { foo: "bar" } } });
|
||||
|
||||
await expect(
|
||||
tool.execute("id", {
|
||||
prompt: "return count",
|
||||
schema: {
|
||||
$id: "https://example.test/llm-task-result",
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
required: ["count"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ details: { json: { count: 1 } } });
|
||||
});
|
||||
|
||||
it("throws on invalid json", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from "node:path";
|
||||
import Ajv from "ajv";
|
||||
import { buildModelAliasIndex, resolveModelRefFromString } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
validateJsonSchemaValue,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { Type } from "typebox";
|
||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "../api.js";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
|
||||
const AjvCtor = Ajv as unknown as typeof import("ajv").default;
|
||||
|
||||
function stripCodeFences(s: string): string {
|
||||
const trimmed = s.trim();
|
||||
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
@@ -293,17 +294,14 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
||||
|
||||
const schema = params.schema;
|
||||
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
||||
const ajv = new AjvCtor({ allErrors: true, strict: false });
|
||||
const validate = ajv.compile(schema);
|
||||
const ok = validate(parsed);
|
||||
if (!ok) {
|
||||
const msg =
|
||||
validate.errors
|
||||
?.map(
|
||||
(e: { instancePath?: string; message?: string }) =>
|
||||
`${e.instancePath || "<root>"} ${e.message || "invalid"}`,
|
||||
)
|
||||
.join("; ") ?? "invalid";
|
||||
const validation = validateJsonSchemaValue({
|
||||
schema: schema as JsonSchemaObject,
|
||||
cacheKey: "llm-task.result",
|
||||
value: parsed,
|
||||
cache: false,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
const msg = validation.errors.map((error) => error.text).join("; ") || "invalid";
|
||||
throw new Error(`LLM JSON did not match schema: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
@@ -488,10 +489,6 @@ function readErrorCode(error: unknown): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
|
||||
@@ -475,6 +475,7 @@ describe("createMattermostInteractionHandler", () => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const req = {
|
||||
destroyed: false,
|
||||
method: params.method ?? "POST",
|
||||
headers: params.headers ?? {},
|
||||
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
|
||||
@@ -484,6 +485,18 @@ describe("createMattermostInteractionHandler", () => {
|
||||
listeners.set(event, existing);
|
||||
return this;
|
||||
},
|
||||
removeListener(event: string, handler: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
listeners.set(
|
||||
event,
|
||||
existing.filter((entry) => entry !== handler),
|
||||
);
|
||||
return this;
|
||||
},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
},
|
||||
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
|
||||
|
||||
req.emitTest = (event: string, ...args: unknown[]) => {
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
|
||||
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
|
||||
import {
|
||||
isTrustedProxyAddress,
|
||||
readRequestBodyWithLimit,
|
||||
resolveClientIp,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||
@@ -353,35 +358,9 @@ export function buildButtonProps(params: {
|
||||
// ── Request body reader ────────────────────────────────────────────────
|
||||
|
||||
function readInteractionBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy();
|
||||
reject(new Error("Request body read timeout"));
|
||||
}, INTERACTION_BODY_TIMEOUT_MS);
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes: INTERACTION_MAX_BODY_BYTES,
|
||||
timeoutMs: INTERACTION_BODY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -9,13 +9,11 @@ import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export function resolveHomePath(input: string): string {
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return path.resolve(input);
|
||||
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/u, os.homedir()));
|
||||
}
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -10,13 +10,11 @@ import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runt
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export function resolveHomePath(input: string): string {
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return path.resolve(input);
|
||||
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/u, os.homedir()));
|
||||
}
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveMSTeamsStorePath } from "./storage.js";
|
||||
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
||||
|
||||
@@ -47,18 +48,6 @@ const STORE_FILENAME = "msteams-polls.json";
|
||||
const MAX_POLLS = 1000;
|
||||
const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeChoiceValue(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
type QaBrowserGateway = {
|
||||
call: (
|
||||
method: string,
|
||||
@@ -180,10 +182,6 @@ function isQaBrowserReady(status: QaBrowserStatus | null | undefined) {
|
||||
return status?.enabled === true && status?.running === true && status?.cdpReady === true;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function waitForQaBrowserReady<T extends QaBrowserStatus = QaBrowserStatus>(
|
||||
env: QaBrowserEnv,
|
||||
params: QaBrowserReadyParams = {},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Agent, createServer, request } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { closeQaHttpServer } from "./bus-server.js";
|
||||
import { closeQaHttpServer, handleQaBusRequest } from "./bus-server.js";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
|
||||
async function listenOnLoopback(server: ReturnType<typeof createServer>): Promise<number> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -57,3 +58,37 @@ describe("closeQaHttpServer", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleQaBusRequest", () => {
|
||||
it("returns a controlled error when a v1 POST body exceeds the limit", async () => {
|
||||
const req = {
|
||||
method: "POST",
|
||||
url: "/v1/reset",
|
||||
headers: { "content-length": String(1024 * 1024 + 1) },
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: "",
|
||||
writeHead(statusCode: number) {
|
||||
this.statusCode = statusCode;
|
||||
},
|
||||
end(payload: string) {
|
||||
this.body = payload;
|
||||
},
|
||||
};
|
||||
|
||||
const handled = await handleQaBusRequest({
|
||||
req: req as never,
|
||||
res: res as never,
|
||||
state: createQaBusState(),
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(JSON.parse(res.body)).toEqual({ error: "Payload too large" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { normalizeAccountId } from "./bus-queries.js";
|
||||
import type { QaBusState } from "./bus-state.js";
|
||||
import type {
|
||||
@@ -15,12 +20,16 @@ import type {
|
||||
QaBusWaitForInput,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8").trim();
|
||||
const QA_HTTP_JSON_MAX_BODY_BYTES = 1024 * 1024;
|
||||
const QA_HTTP_JSON_BODY_TIMEOUT_MS = 5_000;
|
||||
|
||||
export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const text = (
|
||||
await readRequestBodyWithLimit(req, {
|
||||
maxBytes: QA_HTTP_JSON_MAX_BODY_BYTES,
|
||||
timeoutMs: QA_HTTP_JSON_BODY_TIMEOUT_MS,
|
||||
})
|
||||
).trim();
|
||||
return text ? (JSON.parse(text) as unknown) : {};
|
||||
}
|
||||
|
||||
@@ -39,6 +48,14 @@ export function writeError(res: ServerResponse, statusCode: number, error: unkno
|
||||
});
|
||||
}
|
||||
|
||||
export function writeQaRequestBodyLimitError(res: ServerResponse, error: unknown): boolean {
|
||||
if (!isRequestBodyLimitError(error)) {
|
||||
return false;
|
||||
}
|
||||
writeError(res, error.statusCode, requestBodyErrorToText(error.code));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeQaHttpServer(server: Server): Promise<void> {
|
||||
let forceCloseTimer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
@@ -84,9 +101,8 @@ export async function handleQaBusRequest(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = (await readJson(params.req)) as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const body = (await readQaJsonBody(params.req)) as Record<string, unknown>;
|
||||
switch (url.pathname) {
|
||||
case "/v1/reset":
|
||||
params.state.reset();
|
||||
@@ -163,6 +179,9 @@ export async function handleQaBusRequest(params: {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (writeQaRequestBodyLimitError(params.res, error)) {
|
||||
return true;
|
||||
}
|
||||
writeError(params.res, 400, error);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { QA_PROVIDER_SECRET_ENV_VARS } from "./providers/env.js";
|
||||
|
||||
const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([
|
||||
@@ -14,7 +15,7 @@ const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
|
||||
export function redactQaGatewayDebugText(text: string) {
|
||||
let redacted = text;
|
||||
for (const envVar of QA_GATEWAY_DEBUG_SECRET_ENV_VARS) {
|
||||
const escapedEnvVar = envVar.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedEnvVar = escapeRegExp(envVar);
|
||||
redacted = redacted.replace(
|
||||
new RegExp(`\\b(${escapedEnvVar})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "g"),
|
||||
`$1$2<redacted>`,
|
||||
@@ -25,7 +26,7 @@ export function redactQaGatewayDebugText(text: string) {
|
||||
);
|
||||
}
|
||||
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
|
||||
const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedKey = escapeRegExp(key);
|
||||
redacted = redacted.replace(
|
||||
new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
|
||||
`$1$2<redacted>`,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
acquireDebugProxyCaptureStore,
|
||||
resolveDebugProxySettings,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js";
|
||||
import {
|
||||
closeQaHttpServer,
|
||||
handleQaBusRequest,
|
||||
readQaJsonBody,
|
||||
writeError,
|
||||
writeJson,
|
||||
writeQaRequestBodyLimitError,
|
||||
} from "./bus-server.js";
|
||||
import { createQaBusState, type QaBusState } from "./bus-state.js";
|
||||
import { createQaRunnerRuntime } from "./harness-runtime.js";
|
||||
import {
|
||||
@@ -57,6 +64,13 @@ export type {
|
||||
QaLabServerStartParams,
|
||||
} from "./lab-server.types.js";
|
||||
|
||||
export function writeQaLabServerError(res: Parameters<typeof writeError>[0], error: unknown): void {
|
||||
if (writeQaRequestBodyLimitError(res, error)) {
|
||||
return;
|
||||
}
|
||||
writeError(res, 500, error);
|
||||
}
|
||||
|
||||
function countQaLabScenarioRun(scenarios: QaLabScenarioOutcome[]) {
|
||||
return {
|
||||
total: scenarios.length,
|
||||
@@ -94,15 +108,6 @@ function injectKickoffMessage(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8").trim();
|
||||
return text ? (JSON.parse(text) as unknown) : {};
|
||||
}
|
||||
|
||||
function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefaults {
|
||||
if (autoKickoffTarget === "channel") {
|
||||
return {
|
||||
@@ -420,7 +425,7 @@ export async function startQaLabServer(
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/capture/delete-sessions") {
|
||||
const body = (await readJson(req)) as { sessionIds?: unknown };
|
||||
const body = (await readQaJsonBody(req)) as { sessionIds?: unknown };
|
||||
const sessionIds = Array.isArray(body.sessionIds)
|
||||
? body.sessionIds.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
@@ -455,7 +460,7 @@ export async function startQaLabServer(
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/inbound/message") {
|
||||
const body = await readJson(req);
|
||||
const body = await readQaJsonBody(req);
|
||||
writeJson(res, 200, {
|
||||
message: state.addInboundMessage(body as Parameters<QaBusState["addInboundMessage"]>[0]),
|
||||
});
|
||||
@@ -485,7 +490,10 @@ export async function startQaLabServer(
|
||||
writeError(res, 409, "QA suite run already in progress");
|
||||
return;
|
||||
}
|
||||
const selection = normalizeQaRunSelection(await readJson(req), scenarioCatalog.scenarios);
|
||||
const selection = normalizeQaRunSelection(
|
||||
await readQaJsonBody(req),
|
||||
scenarioCatalog.scenarios,
|
||||
);
|
||||
state.reset();
|
||||
latestReport = null;
|
||||
latestScenarioRun = null;
|
||||
@@ -573,7 +581,7 @@ export async function startQaLabServer(
|
||||
}
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
writeError(res, 500, error);
|
||||
writeQaLabServerError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import type { QaProviderMode } from "./model-selection.js";
|
||||
@@ -114,10 +115,6 @@ function createVmSuffix() {
|
||||
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function execFileAsync(file: string, args: string[], options: ExecFileOptions = {}) {
|
||||
return new Promise<ExecResult>((resolve, reject) => {
|
||||
execFile(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { closeQaHttpServer } from "../../bus-server.js";
|
||||
|
||||
type ResponsesInputItem = Record<string, unknown>;
|
||||
@@ -173,12 +175,13 @@ type MockScenarioState = {
|
||||
subagentFanoutPhase: number;
|
||||
};
|
||||
|
||||
const MOCK_OPENAI_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
const MOCK_OPENAI_BODY_TIMEOUT_MS = 30_000;
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
return readRequestBodyWithLimit(req, {
|
||||
maxBytes: MOCK_OPENAI_MAX_BODY_BYTES,
|
||||
timeoutMs: MOCK_OPENAI_BODY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,7 +580,7 @@ function extractExactMarkerDirective(text: string) {
|
||||
}
|
||||
|
||||
function extractLabeledMarkerDirective(text: string, label: string) {
|
||||
const escapedLabel = label.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedLabel = escapeRegExp(label);
|
||||
const backtickedMatch = extractLastCapture(
|
||||
text,
|
||||
new RegExp(`${escapedLabel}:\\s*\`([^\\\`]+)\``, "i"),
|
||||
@@ -592,12 +595,12 @@ function extractLabeledMarkerDirective(text: string, label: string) {
|
||||
}
|
||||
|
||||
function extractQuotedToolArg(text: string, name: string) {
|
||||
const escapedName = name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedName = escapeRegExp(name);
|
||||
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*"([^"]+)"`, "i"));
|
||||
}
|
||||
|
||||
function extractBareToolArg(text: string, name: string) {
|
||||
const escapedName = name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedName = escapeRegExp(name);
|
||||
return extractLastCapture(text, new RegExp(`\\b${escapedName}\\s*=\\s*([^\\s\\\`.,;:!?]+)`, "i"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ?? "");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
|
||||
import {
|
||||
openLocalFileSafely,
|
||||
readRegularFile,
|
||||
@@ -10,7 +11,7 @@ import { getPlatformAdapter } from "../adapter/index.js";
|
||||
import type { SsrfPolicyConfig } from "../adapter/types.js";
|
||||
import { MediaFileType } from "../types.js";
|
||||
import { formatErrorMessage } from "./format.js";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js";
|
||||
import { normalizeOptionalString } from "./string-normalize.js";
|
||||
|
||||
/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
|
||||
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
||||
@@ -133,34 +134,9 @@ export function formatFileSize(bytes: number): string {
|
||||
|
||||
/** Infer a MIME type from the file extension. */
|
||||
export function getMimeType(filePath: string): string {
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Canonical ext → MIME table. Single source of truth. */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
".txt": "text/plain",
|
||||
};
|
||||
|
||||
/** Extensions accepted as image uploads by the QQ Bot media pipeline. */
|
||||
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
|
||||
|
||||
@@ -173,11 +149,12 @@ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bm
|
||||
* `data:image/...;base64,` URL).
|
||||
*/
|
||||
export function getImageMimeType(filePath: string): string | null {
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!IMAGE_EXTENSIONS.has(ext)) {
|
||||
return null;
|
||||
}
|
||||
return MIME_TYPES[ext] ?? null;
|
||||
const mime = mimeTypeFromFilePath(filePath);
|
||||
return mime?.startsWith("image/") ? mime : null;
|
||||
}
|
||||
|
||||
/** Download a remote file into a local directory. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runti
|
||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
|
||||
@@ -80,10 +80,6 @@ import {
|
||||
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Slack reactions.add/remove expect shortcode names, not raw unicode emoji.
|
||||
const UNICODE_TO_SLACK: Record<string, string> = {
|
||||
"👀": "eyes",
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
projectAccountWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listAccountIds, resolveAccount } from "./accounts.js";
|
||||
import { synologyChatApprovalAuth } from "./approval-auth.js";
|
||||
import { sendMessage, sendFileUrl } from "./client.js";
|
||||
@@ -40,10 +41,6 @@ import type { ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "synology-chat";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
|
||||
channelKey: CHANNEL_ID,
|
||||
resolvePolicy: (account) => account.dmPolicy,
|
||||
|
||||
@@ -6,19 +6,17 @@
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
formatErrorMessage,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { z } from "zod";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
const MIN_SEND_INTERVAL_MS = 500;
|
||||
let lastSendTime = 0;
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
// --- Chat user_id resolution ---
|
||||
// Synology Chat uses two different user_id spaces:
|
||||
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
|
||||
@@ -329,7 +327,3 @@ function doPost(url: string, body: string, allowInsecureSsl = false): Promise<bo
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -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) ?? {};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import * as querystring from "node:querystring";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
@@ -16,10 +17,6 @@ import * as synologyClient from "./client.js";
|
||||
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
// One rate limiter per account, created lazily
|
||||
const rateLimiters = new Map<string, RateLimiter>();
|
||||
const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();
|
||||
|
||||
@@ -1,14 +1 @@
|
||||
import { Type } from "typebox";
|
||||
|
||||
export function optionalStringEnum<const T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
export { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import {
|
||||
fetchRemoteMedia,
|
||||
MAX_IMAGE_BYTES,
|
||||
@@ -118,19 +119,7 @@ function getExtensionFromFileName(fileName?: string): string | null {
|
||||
}
|
||||
|
||||
function getExtensionFromContentType(contentType: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/svg+xml": "svg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/ogg": "ogg",
|
||||
};
|
||||
return map[contentType.split(";")[0].trim()] ?? null;
|
||||
return extensionForMime(contentType)?.replace(/^\./u, "") ?? null;
|
||||
}
|
||||
|
||||
function getExtensionFromUrl(url: string): string | null {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user