diff --git a/CHANGELOG.md b/CHANGELOG.md index 78da9fdeedc..517a9149f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries. - Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text. - Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records. +- Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer. - Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship. - Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen. diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index ba0e28d7de1..11acc250c6e 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -144,4 +144,80 @@ describe("byteplus video generation provider", () => { expect(body.resolution).toBe("480p"); expect(body.camera_fixed).toBe(false); }); + + it("reports malformed create JSON with a provider-owned error", async () => { + const release = vi.fn(async () => {}); + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => { + throw new SyntaxError("bad json"); + }, + }, + release, + }); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "bad create response", + cfg: {}, + }), + ).rejects.toThrow("BytePlus video generation failed: malformed JSON response"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects status responses missing a task status", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ id: "task_missing_status" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + id: "task_missing_status", + content: { + video_url: "https://example.com/byteplus.mp4", + }, + }), + }); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "missing status", + cfg: {}, + }), + ).rejects.toThrow("BytePlus video status response missing task status"); + }); + + it("rejects malformed completed content", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ id: "task_malformed_content" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + id: "task_malformed_content", + status: "succeeded", + content: ["https://example.com/byteplus.mp4"], + }), + }); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "malformed content", + cfg: {}, + }), + ).rejects.toThrow("BytePlus video generation completed with malformed content"); + }); }); diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index eb2f8ef8628..3f24cc9b58e 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -27,27 +27,74 @@ const POLL_INTERVAL_MS = 5_000; const MAX_POLL_ATTEMPTS = 120; type BytePlusTaskCreateResponse = { - id?: string; + id?: unknown; }; type BytePlusTaskResponse = { - id?: string; - model?: string; - status?: "running" | "failed" | "queued" | "succeeded" | "cancelled"; - error?: { - code?: string; - message?: string; - }; - content?: { - video_url?: string; - last_frame_url?: string; - file_url?: string; - }; - duration?: number; - ratio?: string; - resolution?: string; + id?: unknown; + model?: unknown; + status?: unknown; + error?: unknown; + content?: unknown; + duration?: unknown; + ratio?: unknown; + resolution?: unknown; }; +type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readBytePlusJsonResponse( + response: Pick, + label: string, +): Promise { + let payload: unknown; + try { + payload = await response.json(); + } catch (cause) { + throw new Error(`${label}: malformed JSON response`, { cause }); + } + if (!isRecord(payload)) { + throw new Error(`${label}: malformed JSON response`); + } + return payload as T; +} + +function readBytePlusTaskStatus(payload: BytePlusTaskResponse): BytePlusTaskStatus { + const status = normalizeOptionalString(payload.status); + switch (status) { + case "running": + case "failed": + case "queued": + case "succeeded": + case "cancelled": + return status; + case undefined: + throw new Error("BytePlus video status response missing task status"); + default: + throw new Error(`BytePlus video status response returned unknown task status: ${status}`); + } +} + +function readBytePlusErrorMessage(error: unknown): string | undefined { + return isRecord(error) ? normalizeOptionalString(error.message) : undefined; +} + +function readBytePlusVideoUrl(payload: BytePlusTaskResponse): string { + const content = payload.content; + if (content !== undefined && !isRecord(content)) { + throw new Error("BytePlus video generation completed with malformed content"); + } + const videoUrl = normalizeOptionalString(content?.video_url); + if (!videoUrl) { + throw new Error("BytePlus video generation completed without a video URL"); + } + return videoUrl; +} + function resolveBytePlusVideoBaseUrl(req: VideoGenerationRequest): string { return ( normalizeOptionalString(req.cfg?.models?.providers?.byteplus?.baseUrl) ?? BYTEPLUS_BASE_URL @@ -100,14 +147,17 @@ async function pollBytePlusTask(params: { provider: "byteplus", requestFailedMessage: "BytePlus video status request failed", }); - const payload = (await response.json()) as BytePlusTaskResponse; - switch (normalizeOptionalString(payload.status)) { + const payload = await readBytePlusJsonResponse( + response, + "BytePlus video status request failed", + ); + switch (readBytePlusTaskStatus(payload)) { case "succeeded": return payload; case "failed": case "cancelled": throw new Error( - normalizeOptionalString(payload.error?.message) || "BytePlus video generation failed", + readBytePlusErrorMessage(payload.error) || "BytePlus video generation failed", ); case "queued": case "running": @@ -292,7 +342,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider }); try { await assertOkOrThrowHttpError(response, "BytePlus video generation failed"); - const submitted = (await response.json()) as BytePlusTaskCreateResponse; + const submitted = await readBytePlusJsonResponse( + response, + "BytePlus video generation failed", + ); const taskId = normalizeOptionalString(submitted.id); if (!taskId) { throw new Error("BytePlus video generation response missing task id"); @@ -307,10 +360,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider baseUrl, fetchFn, }); - const videoUrl = normalizeOptionalString(completed.content?.video_url); - if (!videoUrl) { - throw new Error("BytePlus video generation completed without a video URL"); - } + const videoUrl = readBytePlusVideoUrl(completed); const video = await downloadBytePlusVideo({ url: videoUrl, timeoutMs: createProviderOperationTimeoutResolver({ @@ -321,14 +371,14 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider }); return { videos: [video], - model: completed.model ?? resolvedModel, + model: normalizeOptionalString(completed.model) ?? resolvedModel, metadata: { taskId, - status: completed.status, + status: normalizeOptionalString(completed.status), videoUrl, - ratio: completed.ratio, - resolution: completed.resolution, - duration: completed.duration, + ratio: normalizeOptionalString(completed.ratio), + resolution: normalizeOptionalString(completed.resolution), + duration: typeof completed.duration === "number" ? completed.duration : undefined, }, }; } finally { diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index b502628dc80..6f8acb607ec 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -251,6 +251,56 @@ describe("ollama embedding provider", () => { expect(inputs).toEqual([["a", "bb", "ccc"]]); }); + it("reports malformed embed JSON with a provider-owned error", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("{not json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + await expect(provider.embedQuery("hello")).rejects.toThrow( + "Ollama embed response returned malformed JSON", + ); + }); + + it("rejects non-number embedding values instead of zeroing them", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ embeddings: [["0.1", 0.2]] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + await expect(provider.embedQuery("hello")).rejects.toThrow( + "Ollama embed response contains a non-number embedding value", + ); + }); + it("uses a retrieval query prefix for qwen3 embedding queries", async () => { const fetchMock = mockEmbeddingFetch([1, 0]); diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 675855770dd..1b1ed24c8b7 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -73,8 +73,13 @@ const QUERY_INSTRUCTION_TEMPLATES = [ }, ] as const; -function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { - const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0)); +function sanitizeAndNormalizeEmbedding(vec: unknown[]): number[] { + const sanitized = vec.map((value) => { + if (typeof value !== "number") { + throw new Error("Ollama embed response contains a non-number embedding value"); + } + return Number.isFinite(value) ? value : 0; + }); const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0)); if (magnitude < 1e-10) { return sanitized; @@ -101,6 +106,21 @@ async function withRemoteHttpResponse(params: { } } +async function readOllamaEmbeddingJsonResponse( + response: Pick, +): Promise<{ embeddings?: unknown }> { + let payload: unknown; + try { + payload = await response.json(); + } catch (cause) { + throw new Error("Ollama embed response returned malformed JSON", { cause }); + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("Ollama embed response returned a non-object JSON payload"); + } + return payload as { embeddings?: unknown }; +} + function normalizeEmbeddingModel(model: string, providerId?: string): string { const trimmed = model.trim(); if (!trimmed) { @@ -315,7 +335,7 @@ export async function createOllamaEmbeddingProvider( if (!response.ok) { throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`); } - return (await response.json()) as { embeddings?: unknown }; + return await readOllamaEmbeddingJsonResponse(response); }, }); if (!Array.isArray(json.embeddings)) { diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index 01de394cac5..725cf391a9c 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -169,4 +169,80 @@ describe("runway video generation provider", () => { ).rejects.toThrow("Runway video-to-video currently requires model gen4_aleph."); expect(postJsonRequestMock).not.toHaveBeenCalled(); }); + + it("reports malformed create JSON with a provider-owned error", async () => { + const release = vi.fn(async () => {}); + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => { + throw new SyntaxError("bad json"); + }, + }, + release, + }); + + const provider = buildRunwayVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "runway", + model: "gen4.5", + prompt: "bad create response", + cfg: {}, + }), + ).rejects.toThrow("Runway video generation failed: malformed JSON response"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects status responses missing a task status", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ id: "task-missing-status" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + id: "task-missing-status", + output: ["https://example.com/out.mp4"], + }), + headers: new Headers(), + }); + + const provider = buildRunwayVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "runway", + model: "gen4.5", + prompt: "missing status", + cfg: {}, + }), + ).rejects.toThrow("Runway video status response missing task status"); + }); + + it("rejects malformed completed output URLs", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ id: "task-malformed-output" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + id: "task-malformed-output", + status: "SUCCEEDED", + output: "https://example.com/out.mp4", + }), + headers: new Headers(), + }); + + const provider = buildRunwayVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "runway", + model: "gen4.5", + prompt: "malformed output", + cfg: {}, + }), + ).rejects.toThrow("Runway video generation completed with malformed output URLs"); + }); }); diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index 59e1e4cf759..caa0d90a840 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -36,14 +36,14 @@ const MAX_DURATION_SECONDS = 10; type RunwayTaskStatus = "PENDING" | "RUNNING" | "THROTTLED" | "SUCCEEDED" | "FAILED" | "CANCELLED"; type RunwayTaskCreateResponse = { - id?: string; + id?: unknown; }; type RunwayTaskDetailResponse = { - id?: string; - status?: RunwayTaskStatus; - output?: string[]; - failure?: string | { message?: string } | null; + id?: unknown; + status?: unknown; + output?: unknown; + failure?: unknown; }; type RunwaySourceAsset = Pick; @@ -61,6 +61,66 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]); const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const; const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readRunwayJsonResponse( + response: Pick, + label: string, +): Promise { + let payload: unknown; + try { + payload = await response.json(); + } catch (cause) { + throw new Error(`${label}: malformed JSON response`, { cause }); + } + if (!isRecord(payload)) { + throw new Error(`${label}: malformed JSON response`); + } + return payload as T; +} + +function readRunwayTaskStatus(payload: RunwayTaskDetailResponse): RunwayTaskStatus { + const status = normalizeOptionalString(payload.status); + switch (status) { + case "PENDING": + case "RUNNING": + case "THROTTLED": + case "SUCCEEDED": + case "FAILED": + case "CANCELLED": + return status; + case undefined: + throw new Error("Runway video status response missing task status"); + default: + throw new Error(`Runway video status response returned unknown task status: ${status}`); + } +} + +function readRunwayFailureMessage(failure: unknown): string | undefined { + if (typeof failure === "string") { + return normalizeOptionalString(failure); + } + if (isRecord(failure)) { + return normalizeOptionalString(failure.message); + } + return undefined; +} + +function readRunwayOutputUrls(payload: RunwayTaskDetailResponse): string[] { + if (!Array.isArray(payload.output)) { + throw new Error("Runway video generation completed with malformed output URLs"); + } + const outputUrls = payload.output + .map((value) => normalizeOptionalString(value)) + .filter((value): value is string => Boolean(value)); + if (!outputUrls.length) { + throw new Error("Runway video generation completed without output URLs"); + } + return outputUrls; +} + function resolveRunwayBaseUrl(req: VideoGenerationRequest): string { return ( normalizeOptionalString(req.cfg?.models?.providers?.runway?.baseUrl) ?? DEFAULT_RUNWAY_BASE_URL @@ -226,16 +286,19 @@ async function pollRunwayTask(params: { provider: "runway", requestFailedMessage: "Runway video status request failed", }); - const payload = (await response.json()) as RunwayTaskDetailResponse; - switch (payload.status) { + const payload = await readRunwayJsonResponse( + response, + "Runway video status request failed", + ); + const status = readRunwayTaskStatus(payload); + switch (status) { case "SUCCEEDED": return payload; case "FAILED": case "CANCELLED": throw new Error( - normalizeOptionalString( - typeof payload.failure === "string" ? payload.failure : payload.failure?.message, - ) || `Runway video generation ${normalizeLowercaseStringOrEmpty(payload.status)}`, + readRunwayFailureMessage(payload.failure) || + `Runway video generation ${normalizeLowercaseStringOrEmpty(status)}`, ); case "PENDING": case "RUNNING": @@ -354,7 +417,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { }); try { await assertOkOrThrowHttpError(response, "Runway video generation failed"); - const submitted = (await response.json()) as RunwayTaskCreateResponse; + const submitted = await readRunwayJsonResponse( + response, + "Runway video generation failed", + ); const taskId = normalizeOptionalString(submitted.id); if (!taskId) { throw new Error("Runway video generation response missing task id"); @@ -369,12 +435,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { baseUrl, fetchFn, }); - const outputUrls = completed.output - ?.map((value) => normalizeOptionalString(value)) - .filter((value): value is string => Boolean(value)); - if (!outputUrls?.length) { - throw new Error("Runway video generation completed without output URLs"); - } + const outputUrls = readRunwayOutputUrls(completed); const videos = await downloadRunwayVideos({ urls: outputUrls, timeoutMs: createProviderOperationTimeoutResolver({ @@ -388,7 +449,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider { model: normalizeOptionalString(req.model) ?? DEFAULT_RUNWAY_MODEL, metadata: { taskId, - status: completed.status, + status: normalizeOptionalString(completed.status), endpoint, outputUrls, },