diff --git a/CHANGELOG.md b/CHANGELOG.md index d61c3daff35..bd273066ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys. +- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys. - QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee. - Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee. - Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys. diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index cec51bf8735..b537b12fa65 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -119,6 +119,49 @@ describe("Google image-generation provider", () => { }); }); + it("passes request SSRF policy to the provider HTTP helper", async () => { + mockGoogleApiKeyAuth(); + const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({ + response: new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + finalUrl: + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent", + release: async () => {}, + }); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a cat", + cfg: {}, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }); + + expect(postJsonRequest).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); + }); + it("accepts OAuth JSON auth and inline_data responses", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: JSON.stringify({ token: "oauth-token" }), diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 3bcdebffd7f..f125eb4d5c8 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -177,6 +177,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider { fetchFn: fetch, pinDns: false, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); diff --git a/extensions/minimax/image-generation-provider.test.ts b/extensions/minimax/image-generation-provider.test.ts index 794d8c9f820..c54f4c6e640 100644 --- a/extensions/minimax/image-generation-provider.test.ts +++ b/extensions/minimax/image-generation-provider.test.ts @@ -1,4 +1,5 @@ import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime"; +import * as providerHttp from "openclaw/plugin-sdk/provider-http"; import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -93,6 +94,36 @@ describe("minimax image-generation provider", () => { }); }); + it("passes request SSRF policy to the provider HTTP helper", async () => { + mockMinimaxApiKey(); + const postJsonRequest = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({ + response: new Response( + JSON.stringify({ + data: { image_base64: [Buffer.from("png-data").toString("base64")] }, + base_resp: { status_code: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + finalUrl: "https://api.minimax.io/v1/image_generation", + release: async () => {}, + }); + + const provider = buildMinimaxImageGenerationProvider(); + await provider.generateImage({ + provider: "minimax", + model: "image-01", + prompt: "draw a cat", + cfg: {}, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }); + + expect(postJsonRequest).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); + }); + it("keeps the dedicated global image endpoint when text config uses the global API host", async () => { mockMinimaxApiKey(); const fetchMock = mockSuccessfulMinimaxImageResponse(); diff --git a/extensions/minimax/image-generation-provider.ts b/extensions/minimax/image-generation-provider.ts index 3496c33344c..fe47fed2805 100644 --- a/extensions/minimax/image-generation-provider.ts +++ b/extensions/minimax/image-generation-provider.ts @@ -155,6 +155,7 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider timeoutMs: req.timeoutMs, fetchFn: fetch, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); try { diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index bcd08cb4be0..3aa7d0fb18f 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -364,6 +364,25 @@ describe("openai image generation provider", () => { expect(result.images).toHaveLength(1); }); + it("propagates request SSRF policy to JSON image requests", async () => { + mockGeneratedPngResponse(); + + const provider = buildOpenAIImageGenerationProvider(); + await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "test", + cfg: {}, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }); + + expect(postJsonRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); + }); + it("forwards generation count and custom size overrides", async () => { mockGeneratedPngResponse(); @@ -1068,6 +1087,7 @@ describe("openai image generation provider", () => { }, }, authStore, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, }); expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith({ @@ -1083,6 +1103,7 @@ describe("openai image generation provider", () => { expect.objectContaining({ url: "http://127.0.0.1:44220/backend-api/codex/responses", allowPrivateNetwork: true, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, }), ); expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-image")); diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index 0cff725b38c..c1685bf5872 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -681,6 +681,7 @@ async function generateOpenAICodexImage(params: { timeoutMs, fetchFn: fetch, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); const { response, release } = requestResult; @@ -842,6 +843,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { timeoutMs, fetchFn: fetch, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); })() @@ -864,6 +866,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { timeoutMs, fetchFn: fetch, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); })(); diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 2110c40ff0a..7cb2176ac1b 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -106,6 +106,7 @@ describe("openrouter image generation provider", () => { resolution: "2K", count: 2, timeoutMs: 12_345, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, cfg: { models: { providers: { @@ -131,6 +132,7 @@ describe("openrouter image generation provider", () => { expect.objectContaining({ url: "https://custom.openrouter.test/api/v1/chat/completions", timeoutMs: 12_345, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, body: expect.objectContaining({ model: "google/gemini-3.1-flash-image-preview", modalities: ["image", "text"], diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index 75fedb8ed11..2051d4484a4 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -249,6 +249,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchFn: fetch, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); diff --git a/extensions/vydra/image-generation-provider.test.ts b/extensions/vydra/image-generation-provider.test.ts index ad01b383f00..1b531d69734 100644 --- a/extensions/vydra/image-generation-provider.test.ts +++ b/extensions/vydra/image-generation-provider.test.ts @@ -66,6 +66,41 @@ describe("vydra image-generation provider", () => { }); }); + it("passes request SSRF policy to the image creation request", async () => { + stubVydraApiKey(); + const fetchMock = stubFetch( + jsonResponse({ + jobId: "job-123", + status: "completed", + imageUrl: "https://cdn.vydra.ai/generated/test.png", + }), + binaryResponse("png-data", "image/png"), + ); + + const provider = buildVydraImageGenerationProvider(); + await provider.generateImage({ + provider: "vydra", + model: "grok-imagine", + prompt: "draw a cat", + cfg: { + models: { + providers: { + vydra: { + baseUrl: "https://198.18.0.10/api/v1", + }, + }, + }, + } as never, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://198.18.0.10/api/v1/models/grok-imagine", + expect.objectContaining({ method: "POST" }), + ); + }); + it("polls jobs when the create response is not completed yet", async () => { stubVydraApiKey(); const fetchMock = stubFetch( diff --git a/extensions/vydra/image-generation-provider.ts b/extensions/vydra/image-generation-provider.ts index c2c33b88e12..fc71fb88626 100644 --- a/extensions/vydra/image-generation-provider.ts +++ b/extensions/vydra/image-generation-provider.ts @@ -67,6 +67,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider { timeoutMs: req.timeoutMs, fetchFn, allowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index dd61db58eb3..9a6204aed33 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -971,7 +971,7 @@ describe("createImageGenerateTool", () => { it("passes web_fetch SSRF policy to remote reference images", async () => { stubImageGenerationProviders(); - stubEditedImageFlow({ width: 1024, height: 1024 }); + const generateImage = stubEditedImageFlow({ width: 1024, height: 1024 }); const defaultTool = requireImageGenerateTool( createImageGenerateTool({ config: { @@ -1015,6 +1015,11 @@ describe("createImageGenerateTool", () => { ssrfPolicy: { allowRfc2544BenchmarkRange: true }, }), ); + expect(generateImage).toHaveBeenLastCalledWith( + expect.objectContaining({ + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); }); it("ignores non-finite mediaMaxMb when loading reference images", async () => { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 1d4f7c25283..4f46e88319a 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -742,6 +742,7 @@ export function createImageGenerateTool(options?: { inputImages, timeoutMs, providerOptions, + ssrfPolicy: remoteMediaSsrfPolicy, }); const ignoredOverrides = result.ignoredOverrides ?? []; const displayProvider = sanitizeInlineDirectiveText(result.provider); diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 91360253d8e..d22667403b8 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -166,6 +166,7 @@ describe("OpenAI-compatible image provider helper", () => { prompt: "draw a square", count: 2, size: "512x512", + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, cfg: { models: { providers: { @@ -188,6 +189,7 @@ describe("OpenAI-compatible image provider helper", () => { expect.objectContaining({ url: "https://sample.example/v1/images/generations", allowPrivateNetwork: true, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, dispatcherPolicy: { request: { allowPrivateNetwork: true } }, body: { model: "custom-image", diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 89fef5bc27b..c8ffbf6fb61 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -234,6 +234,7 @@ export function createOpenAiCompatibleImageGenerationProvider( timeoutMs, fetchFn: fetch, allowPrivateNetwork: resolvedAllowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }) : postJsonRequest({ @@ -247,6 +248,7 @@ export function createOpenAiCompatibleImageGenerationProvider( timeoutMs, fetchFn: fetch, allowPrivateNetwork: resolvedAllowPrivateNetwork, + ssrfPolicy: req.ssrfPolicy, dispatcherPolicy, }); diff --git a/src/image-generation/runtime-types.ts b/src/image-generation/runtime-types.ts index 24332fabf72..fcada319cb7 100644 --- a/src/image-generation/runtime-types.ts +++ b/src/image-generation/runtime-types.ts @@ -1,6 +1,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { FallbackAttempt } from "../agents/model-fallback.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { GeneratedImageAsset, ImageGenerationBackground, @@ -32,6 +33,8 @@ export type GenerateImageParams = { /** Optional per-request provider timeout in milliseconds. */ timeoutMs?: number; providerOptions?: ImageGenerationProviderOptions; + /** SSRF policy to propagate into image-generation provider HTTP calls. */ + ssrfPolicy?: SsrFPolicy; }; export type GenerateImageRuntimeResult = { diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index 0219ae1763b..064addbc34f 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -43,15 +43,17 @@ describe("image-generation runtime", () => { const authStore = { version: 1, profiles: {} } as const; let seenAuthStore: unknown; let seenTimeoutMs: number | undefined; + let seenSsrfPolicy: unknown; const provider: ImageGenerationProvider = { id: "image-plugin", capabilities: { generate: {}, edit: { enabled: false }, }, - async generateImage(req: { authStore?: unknown; timeoutMs?: number }) { + async generateImage(req: { authStore?: unknown; timeoutMs?: number; ssrfPolicy?: unknown }) { seenAuthStore = req.authStore; seenTimeoutMs = req.timeoutMs; + seenSsrfPolicy = req.ssrfPolicy; return { images: [ { @@ -78,6 +80,7 @@ describe("image-generation runtime", () => { agentDir: "/tmp/agent", authStore, timeoutMs: 12_345, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, }); expect(result.provider).toBe("image-plugin"); @@ -85,6 +88,7 @@ describe("image-generation runtime", () => { expect(result.attempts).toStrictEqual([]); expect(seenAuthStore).toEqual(authStore); expect(seenTimeoutMs).toBe(12_345); + expect(seenSsrfPolicy).toEqual({ allowRfc2544BenchmarkRange: true }); expect(result.images).toEqual([ { buffer: Buffer.from("png-bytes"), diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 7cda8fab5ac..bd8d91f9440 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -118,6 +118,7 @@ export async function generateImage( inputImages: params.inputImages, ...(timeoutMs !== undefined ? { timeoutMs } : {}), providerOptions: params.providerOptions, + ssrfPolicy: params.ssrfPolicy, }); if (!Array.isArray(result.images) || result.images.length === 0) { throw new Error("Image generation provider returned no images."); diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index f912c99b675..8c69fbd6395 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -1,5 +1,6 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { MediaNormalizationEntry } from "../media-generation/normalization.types.js"; export type GeneratedImageAsset = { @@ -75,6 +76,7 @@ export type ImageGenerationRequest = { background?: ImageGenerationBackground; inputImages?: ImageGenerationSourceImage[]; providerOptions?: ImageGenerationProviderOptions; + ssrfPolicy?: SsrFPolicy; }; export type ImageGenerationResult = { diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index dd415c5db6b..ce49b129de8 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -365,6 +365,28 @@ describe("fetchWithTimeoutGuarded", () => { }); }); + it("merges full SSRF policy into JSON request guards", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com", + release: async () => {}, + }); + + await postJsonRequest({ + url: "https://api.example.com/v1/test", + headers: new Headers(), + body: { ok: true }, + fetchFn: fetch, + allowPrivateNetwork: true, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }); + + expect(getFirstGuardedFetchCall().policy).toEqual({ + allowPrivateNetwork: true, + allowRfc2544BenchmarkRange: true, + }); + }); + it("forwards explicit pinDns overrides to JSON requests", async () => { fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(null, { status: 200 }), diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index e1a3895c2e1..e8a58c03204 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -319,15 +319,30 @@ export async function fetchWithTimeoutGuarded( type GuardedPostRequestOptions = NonNullable[4]>; +function mergeGuardedPostSsrfPolicy(params: { + ssrfPolicy?: SsrFPolicy; + allowPrivateNetwork?: boolean; +}): SsrFPolicy | undefined { + if (!params.ssrfPolicy) { + return params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + } + if (!params.allowPrivateNetwork) { + return params.ssrfPolicy; + } + return { ...params.ssrfPolicy, allowPrivateNetwork: true }; +} + function resolveGuardedPostRequestOptions(params: { pinDns?: boolean; allowPrivateNetwork?: boolean; + ssrfPolicy?: SsrFPolicy; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; mode?: GuardedFetchMode; }): GuardedPostRequestOptions | undefined { if ( !params.allowPrivateNetwork && + !params.ssrfPolicy && !params.dispatcherPolicy && params.pinDns === undefined && !params.auditContext && @@ -335,8 +350,9 @@ function resolveGuardedPostRequestOptions(params: { ) { return undefined; } + const ssrfPolicy = mergeGuardedPostSsrfPolicy(params); return { - ...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(ssrfPolicy ? { ssrfPolicy } : {}), ...(params.pinDns !== undefined ? { pinDns: params.pinDns } : {}), ...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}), ...(params.auditContext ? { auditContext: params.auditContext } : {}), @@ -352,6 +368,7 @@ export async function postTranscriptionRequest(params: { fetchFn: typeof fetch; pinDns?: boolean; allowPrivateNetwork?: boolean; + ssrfPolicy?: SsrFPolicy; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; /** @@ -382,6 +399,7 @@ export async function postJsonRequest(params: { fetchFn: typeof fetch; pinDns?: boolean; allowPrivateNetwork?: boolean; + ssrfPolicy?: SsrFPolicy; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; /** @@ -412,6 +430,7 @@ export async function postMultipartRequest(params: { fetchFn: typeof fetch; pinDns?: boolean; allowPrivateNetwork?: boolean; + ssrfPolicy?: SsrFPolicy; dispatcherPolicy?: PinnedDispatcherPolicy; auditContext?: string; /**