diff --git a/CHANGELOG.md b/CHANGELOG.md index 110744c7eec..8565f2288f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as `openclaw cron` no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson. - Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc. - CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana. +- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc. ## 2026.4.12 diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 1c07015a5bb..6e5bbcede9c 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -348,6 +348,7 @@ describe("createImageGenerateTool", () => { config: { agents: { defaults: { + mediaMaxMb: 8, imageGenerationModel: { primary: "openai/gpt-image-1", }, @@ -375,6 +376,7 @@ describe("createImageGenerateTool", () => { cfg: { agents: { defaults: { + mediaMaxMb: 8, imageGenerationModel: { primary: "openai/gpt-image-1", }, @@ -394,7 +396,7 @@ describe("createImageGenerateTool", () => { Buffer.from("png-1"), "image/png", "tool-image-generation", - undefined, + 8 * 1024 * 1024, "cats/output.png", ); expect(saveMediaBuffer).toHaveBeenNthCalledWith( @@ -402,7 +404,7 @@ describe("createImageGenerateTool", () => { Buffer.from("png-2"), "image/png", "tool-image-generation", - undefined, + 8 * 1024 * 1024, "cats/output.png", ); expect(result).toMatchObject({ diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 8e8e78df252..6cb97318ab0 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -12,6 +12,7 @@ import type { ImageGenerationResolution, ImageGenerationSourceImage, } from "../../image-generation/types.js"; +import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js"; import { getImageMetadata } from "../../media/image-ops.js"; import { saveMediaBuffer } from "../../media/store.js"; import { loadWebMedia } from "../../media/web-media.js"; @@ -178,14 +179,6 @@ function normalizeReferenceImages(args: Record): string[] { }); } -function pickConfiguredMediaMaxBytes(cfg?: OpenClawConfig): number | undefined { - const configured = cfg?.agents?.defaults?.mediaMaxMb; - if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { - return Math.floor(configured * 1024 * 1024); - } - return undefined; -} - function resolveSelectedImageGenerationProvider(params: { config?: OpenClawConfig; imageGenerationModelConfig: ToolModelConfig; @@ -467,9 +460,10 @@ export function createImageGenerateTool(options?: { modelOverride: model, }); const count = resolveRequestedCount(params); + const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(effectiveCfg); const loadedReferenceImages = await loadReferenceImages({ imageInputs, - maxBytes: pickConfiguredMediaMaxBytes(effectiveCfg), + maxBytes: configuredMediaMaxBytes, workspaceDir: options?.workspaceDir, sandboxConfig, }); @@ -542,7 +536,7 @@ export function createImageGenerateTool(options?: { image.buffer, image.mimeType, "tool-image-generation", - undefined, + configuredMediaMaxBytes, filename || image.fileName, ), ), diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index be0b33f99c3..377d00e21de 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -167,7 +167,7 @@ describe("createMusicGenerateTool", () => { lyrics: ["wake the city up"], metadata: { taskId: "music-task-1" }, }); - vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({ + const saveSpy = vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({ path: "/tmp/generated-night-drive.mp3", id: "generated-night-drive.mp3", size: 11, @@ -178,6 +178,7 @@ describe("createMusicGenerateTool", () => { config: asConfig({ agents: { defaults: { + mediaMaxMb: 8, musicGenerationModel: { primary: "google/lyria-3-clip-preview" }, }, }, @@ -194,6 +195,13 @@ describe("createMusicGenerateTool", () => { }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + expect(saveSpy).toHaveBeenCalledWith( + Buffer.from("music-bytes"), + "audio/mpeg", + "tool-music-generation", + 8 * 1024 * 1024, + "night-drive.mp3", + ); expect(text).toContain("Generated 1 track with google/lyria-3-clip-preview."); expect(text).toContain("Lyrics returned."); expect(text).toContain("MEDIA:/tmp/generated-night-drive.mp3"); diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index c1c92a1d28f..07baa26c6e4 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -3,6 +3,7 @@ import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js"; import { saveMediaBuffer } from "../../media/store.js"; import { loadWebMedia } from "../../media/web-media.js"; import { resolveMusicGenerationModeCapabilities } from "../../music-generation/capabilities.js"; @@ -359,13 +360,14 @@ async function executeMusicGenerationJob(params: { progressSummary: "Saving generated music", }); } + const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(params.effectiveCfg); const savedTracks = await Promise.all( result.tracks.map((track) => saveMediaBuffer( track.buffer, track.mimeType, "tool-music-generation", - undefined, + configuredMediaMaxBytes, params.filename || track.fileName, ), ), diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index 75189c052df..405c4d6f50b 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -134,7 +134,7 @@ describe("createVideoGenerateTool", () => { ], metadata: { taskId: "task-1" }, }); - vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({ + const saveSpy = vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({ path: "/tmp/generated-lobster.mp4", id: "generated-lobster.mp4", size: 11, @@ -145,6 +145,7 @@ describe("createVideoGenerateTool", () => { config: asConfig({ agents: { defaults: { + mediaMaxMb: 8, videoGenerationModel: { primary: "qwen/wan2.6-t2v" }, }, }, @@ -158,6 +159,13 @@ describe("createVideoGenerateTool", () => { const result = await tool.execute("call-1", { prompt: "friendly lobster surfing" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + expect(saveSpy).toHaveBeenCalledWith( + Buffer.from("video-bytes"), + "video/mp4", + "tool-video-generation", + 8 * 1024 * 1024, + "lobster.mp4", + ); expect(text).toContain("Generated 1 video with qwen/wan2.6-t2v."); expect(text).toContain("MEDIA:/tmp/generated-lobster.mp4"); expect(result.details).toMatchObject({ diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index d1c136c561c..4798eafba5a 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -3,6 +3,7 @@ import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js"; import { saveMediaBuffer } from "../../media/store.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; @@ -611,13 +612,14 @@ async function executeVideoGenerationJob(params: { ); } + const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(params.effectiveCfg); const savedVideos = await Promise.all( bufferVideos.map((video) => saveMediaBuffer( video.buffer, video.mimeType, "tool-video-generation", - undefined, + configuredMediaMaxBytes, params.filename || video.fileName, ), ), diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index 3f763924251..e6258c230ef 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -165,7 +165,7 @@ describe("createReplyMediaPathNormalizer", () => { path: "/Users/peter/.openclaw/media/outbound/persisted.png", }); const normalize = createReplyMediaPathNormalizer({ - cfg: {}, + cfg: { agents: { defaults: { mediaMaxMb: 8 } } }, sessionKey: "session-key", workspaceDir: "/Users/peter/.openclaw/workspace", }); @@ -180,6 +180,7 @@ describe("createReplyMediaPathNormalizer", () => { "/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png", undefined, "outbound", + 8 * 1024 * 1024, ); expect(result).toMatchObject({ mediaUrl: "/Users/peter/.openclaw/media/outbound/persisted.png", @@ -206,7 +207,7 @@ describe("createReplyMediaPathNormalizer", () => { mediaUrls: [tmpVoicePath], }); - expect(saveMediaSource).toHaveBeenCalledWith(tmpVoicePath, undefined, "outbound"); + expect(saveMediaSource).toHaveBeenCalledWith(tmpVoicePath, undefined, "outbound", undefined); expect(result).toMatchObject({ mediaUrl: "/Users/peter/.openclaw/media/outbound/tts-voice.opus", mediaUrls: ["/Users/peter/.openclaw/media/outbound/tts-voice.opus"], diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index 9f0192b7129..473dd17b91a 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -8,6 +8,7 @@ import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js"; import { saveMediaSource } from "../../media/store.js"; import { resolveConfigDir } from "../../utils.js"; import type { ReplyPayload } from "../types.js"; @@ -103,6 +104,7 @@ export function createReplyMediaPathNormalizer(params: { cfg: params.cfg, agentId, }); + const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(params.cfg); let sandboxRootPromise: Promise | undefined; const persistedMediaBySource = new Map>(); @@ -133,7 +135,7 @@ export function createReplyMediaPathNormalizer(params: { if (cached) { return await cached; } - const persistPromise = saveMediaSource(media, undefined, "outbound") + const persistPromise = saveMediaSource(media, undefined, "outbound", configuredMediaMaxBytes) .then((saved) => saved.path) .catch((err) => { persistedMediaBySource.delete(media); diff --git a/src/media/configured-max-bytes.ts b/src/media/configured-max-bytes.ts new file mode 100644 index 00000000000..1418b106c0b --- /dev/null +++ b/src/media/configured-max-bytes.ts @@ -0,0 +1,11 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const MB = 1024 * 1024; + +export function resolveConfiguredMediaMaxBytes(cfg?: OpenClawConfig): number | undefined { + const configured = cfg?.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return undefined; +} diff --git a/src/media/store.test.ts b/src/media/store.test.ts index e58030e7721..9c70f208c9e 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -258,6 +258,37 @@ describe("media store", () => { }); }, }, + { + name: "allows callers to override the default source size limit", + run: async () => { + await withTempStore(async (store, home) => { + const sourcePath = path.join(home, "large-source.bin"); + await fs.writeFile(sourcePath, Buffer.alloc(6 * 1024 * 1024, 0x41)); + + const saved = await store.saveMediaSource( + sourcePath, + undefined, + "outbound", + 8 * 1024 * 1024, + ); + + expect(saved.size).toBe(6 * 1024 * 1024); + }); + }, + }, + { + name: "reports the effective source size limit in too-large errors", + run: async () => { + await withTempStore(async (store, home) => { + const sourcePath = path.join(home, "too-large-source.bin"); + await fs.writeFile(sourcePath, Buffer.alloc(7 * 1024 * 1024, 0x41)); + + await expect( + store.saveMediaSource(sourcePath, undefined, "outbound", 6 * 1024 * 1024), + ).rejects.toThrow("Media exceeds 6MB limit"); + }); + }, + }, { name: "retries buffer writes when cleanup prunes the target directory", run: async () => { diff --git a/src/media/store.ts b/src/media/store.ts index 667d9329801..f643e51f0b8 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -30,6 +30,10 @@ const defaultHttpRequestImpl: RequestImpl = httpRequest; const defaultHttpsRequestImpl: RequestImpl = httpsRequest; const defaultResolvePinnedHostnameImpl: ResolvePinnedHostnameImpl = resolvePinnedHostname; +function formatMediaLimitMb(maxBytes: number): string { + return `${(maxBytes / (1024 * 1024)).toFixed(0)}MB`; +} + let httpRequestImpl: RequestImpl = defaultHttpRequestImpl; let httpsRequestImpl: RequestImpl = defaultHttpsRequestImpl; let resolvePinnedHostnameImpl: ResolvePinnedHostnameImpl = defaultResolvePinnedHostnameImpl; @@ -184,6 +188,7 @@ async function downloadToFile( dest: string, headers?: Record, maxRedirects = 5, + maxBytes = MAX_BYTES, ): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> { return await new Promise((resolve, reject) => { let parsedUrl: URL; @@ -201,7 +206,6 @@ async function downloadToFile( resolvePinnedHostnameImpl(parsedUrl.hostname) .then((pinned) => { const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => { - // Follow redirects if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { const location = res.headers.location; if (!location || maxRedirects <= 0) { @@ -213,7 +217,7 @@ async function downloadToFile( new URL(redirectUrl).origin === parsedUrl.origin ? headers : retainSafeHeadersForCrossOriginRedirect(headers); - resolve(downloadToFile(redirectUrl, dest, redirectHeaders, maxRedirects - 1)); + resolve(downloadToFile(redirectUrl, dest, redirectHeaders, maxRedirects - 1, maxBytes)); return; } if (!res.statusCode || res.statusCode >= 400) { @@ -230,8 +234,8 @@ async function downloadToFile( sniffChunks.push(chunk); sniffLen += chunk.length; } - if (total > MAX_BYTES) { - req.destroy(new Error("Media exceeds 5MB limit")); + if (total > maxBytes) { + req.destroy(new Error(`Media exceeds ${formatMediaLimitMb(maxBytes)} limit`)); } }); pipeline(res, out) @@ -323,7 +327,10 @@ export class SaveMediaSourceError extends Error { } } -function toSaveMediaSourceError(err: SafeOpenLikeError): SaveMediaSourceError { +function toSaveMediaSourceError( + err: SafeOpenLikeError, + maxBytes = MAX_BYTES, +): SaveMediaSourceError { switch (err.code) { case "symlink": return new SaveMediaSourceError("invalid-path", "Media path must not be a symlink", { @@ -336,7 +343,11 @@ function toSaveMediaSourceError(err: SafeOpenLikeError): SaveMediaSourceError { cause: err, }); case "too-large": - return new SaveMediaSourceError("too-large", "Media exceeds 5MB limit", { cause: err }); + return new SaveMediaSourceError( + "too-large", + `Media exceeds ${formatMediaLimitMb(maxBytes)} limit`, + { cause: err }, + ); case "not-found": return new SaveMediaSourceError("not-found", "Media path does not exist", { cause: err }); case "outside-workspace": @@ -355,6 +366,7 @@ export async function saveMediaSource( source: string, headers?: Record, subdir = "", + maxBytes = MAX_BYTES, ): Promise { const baseDir = resolveMediaDir(); const dir = subdir ? path.join(baseDir, subdir) : baseDir; @@ -364,7 +376,7 @@ export async function saveMediaSource( if (looksLikeUrl(source)) { const tempDest = path.join(dir, `${baseId}.tmp`); const { headerMime, sniffBuffer, size } = await retryAfterRecreatingDir(dir, () => - downloadToFile(source, tempDest, headers), + downloadToFile(source, tempDest, headers, 5, maxBytes), ); const mime = await detectMime({ buffer: sniffBuffer, @@ -377,9 +389,8 @@ export async function saveMediaSource( await fs.rename(tempDest, finalDest); return buildSavedMediaResult({ dir, id, size, contentType: mime }); } - // local path try { - const { buffer, stat } = await readLocalFileSafely({ filePath: source, maxBytes: MAX_BYTES }); + const { buffer, stat } = await readLocalFileSafely({ filePath: source, maxBytes }); const mime = await detectMime({ buffer, filePath: source }); const ext = extensionForMime(mime) ?? path.extname(source); const id = buildSavedMediaId({ baseId, ext }); @@ -387,7 +398,7 @@ export async function saveMediaSource( return buildSavedMediaResult({ dir, id, size: stat.size, contentType: mime }); } catch (err) { if (isSafeOpenError(err)) { - throw toSaveMediaSourceError(err); + throw toSaveMediaSourceError(err, maxBytes); } throw err; } @@ -401,7 +412,7 @@ export async function saveMediaBuffer( originalFilename?: string, ): Promise { if (buffer.byteLength > maxBytes) { - throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); + throw new Error(`Media exceeds ${formatMediaLimitMb(maxBytes)} limit`); } const dir = path.join(resolveMediaDir(), subdir); await fs.mkdir(dir, { recursive: true, mode: 0o700 });