mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
src/media/configured-max-bytes.ts
Normal file
11
src/media/configured-max-bytes.ts
Normal 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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user