fix: honor configured store limits (#66240) (thanks @neeravmakwana)

* fix(media): honor configured store limits

* fix(media): report effective source limits

* refactor(media): distill configured limit wiring

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Neerav Makwana
2026-04-14 02:23:20 -04:00
committed by GitHub
parent 0381852c26
commit a743b30b8b
12 changed files with 103 additions and 30 deletions

View File

@@ -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

View File

@@ -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({

View File

@@ -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, unknown>): 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,
),
),

View File

@@ -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");

View File

@@ -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,
),
),

View File

@@ -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({

View File

@@ -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,
),
),

View File

@@ -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"],

View File

@@ -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<string | undefined> | undefined;
const persistedMediaBySource = new Map<string, Promise<string>>();
@@ -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);

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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<string, string>,
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<string, string>,
subdir = "",
maxBytes = MAX_BYTES,
): Promise<SavedMedia> {
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<SavedMedia> {
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 });