fix(tlon): cap inbound image downloads (#58223)

This commit is contained in:
Vincent Koc
2026-03-31 19:40:15 +09:00
committed by GitHub
parent 9023a0436c
commit 2194587d70
3 changed files with 156 additions and 48 deletions

View File

@@ -0,0 +1,101 @@
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
};
});
describe("tlon monitor media", () => {
async function loadMediaModule() {
const mediaRuntime = await import("openclaw/plugin-sdk/media-runtime");
const mediaModule = await import("./media.js");
return {
fetchRemoteMedia: vi.mocked(mediaRuntime.fetchRemoteMedia),
saveMediaBuffer: vi.mocked(mediaRuntime.saveMediaBuffer),
...mediaModule,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caps extracted images at eight per message", async () => {
const { extractImageBlocks } = await loadMediaModule();
const content = Array.from({ length: 10 }, (_, index) => ({
block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } },
}));
const images = extractImageBlocks(content);
expect(images).toHaveLength(8);
expect(images.map((image) => image.url)).toEqual(
Array.from({ length: 8 }, (_, index) => `https://example.com/${index}.png`),
);
});
it("stores fetched media through the shared inbound media store with the image cap", async () => {
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
fetchRemoteMedia.mockResolvedValue({
buffer: Buffer.from("image-data"),
contentType: "image/png",
fileName: "photo.png",
});
saveMediaBuffer.mockResolvedValue({
id: "photo---uuid.png",
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
size: "image-data".length,
contentType: "image/png",
});
const result = await downloadMedia("https://example.com/photo.png");
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/photo.png",
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: 30_000,
requestInit: { method: "GET" },
}),
);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("image-data"),
"image/png",
"inbound",
MAX_IMAGE_BYTES,
"photo.png",
);
expect(result).toEqual({
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
contentType: "image/png",
originalUrl: "https://example.com/photo.png",
});
});
it("returns null when the fetch exceeds the image cap", async () => {
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
fetchRemoteMedia.mockRejectedValue(
new Error(
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
),
);
const result = await downloadMedia("https://example.com/photo.png");
expect(result).toBeNull();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});

View File

@@ -1,15 +1,15 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { mkdir, writeFile } from "node:fs/promises";
import * as path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { fetchWithSsrFGuard } from "../../runtime-api.js";
import {
fetchRemoteMedia,
MAX_IMAGE_BYTES,
saveMediaBuffer,
} from "openclaw/plugin-sdk/media-runtime";
import { getDefaultSsrFPolicy } from "../urbit/context.js";
// Default to OpenClaw workspace media directory
const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound");
const MAX_IMAGES_PER_MESSAGE = 8;
const TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
export interface ExtractedImage {
url: string;
@@ -39,6 +39,9 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] {
url: verse.block.image.src,
alt: verse.block.image.alt,
});
if (images.length >= MAX_IMAGES_PER_MESSAGE) {
break;
}
}
}
@@ -51,7 +54,7 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] {
*/
export async function downloadMedia(
url: string,
mediaDir: string = DEFAULT_MEDIA_DIR,
mediaDir?: string,
): Promise<DownloadedMedia | null> {
try {
// Validate URL is http/https before fetching
@@ -61,56 +64,59 @@ export async function downloadMedia(
return null;
}
// Ensure media directory exists
await mkdir(mediaDir, { recursive: true });
// Fetch with SSRF protection
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
const { response, release } = await fetchWithSsrFGuard({
const fetched = await fetchRemoteMedia({
url,
init: { method: "GET" },
policy: getDefaultSsrFPolicy(),
auditContext: "tlon-media-download",
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: getDefaultSsrFPolicy(),
requestInit: { method: "GET" },
});
try {
if (!response.ok) {
console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`);
return null;
}
// Determine content type and extension
const contentType = response.headers.get("content-type") || "application/octet-stream";
const ext = getExtensionFromContentType(contentType) || getExtensionFromUrl(url) || "bin";
// Generate unique filename
const filename = `${randomUUID()}.${ext}`;
const localPath = path.join(mediaDir, filename);
// Stream to file
const body = response.body;
if (!body) {
console.error(`[tlon-media] No response body for ${url}`);
return null;
}
const writeStream = createWriteStream(localPath);
await pipeline(Readable.fromWeb(body as any), writeStream);
if (!mediaDir) {
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
MAX_IMAGE_BYTES,
fetched.fileName,
);
return {
localPath,
contentType,
localPath: saved.path,
contentType: saved.contentType ?? fetched.contentType ?? "application/octet-stream",
originalUrl: url,
};
} finally {
await release();
}
} catch (error: any) {
console.error(`[tlon-media] Error downloading ${url}: ${error?.message ?? String(error)}`);
await mkdir(mediaDir, { recursive: true });
const ext =
getExtensionFromFileName(fetched.fileName) ||
getExtensionFromContentType(fetched.contentType ?? "") ||
getExtensionFromUrl(url) ||
"bin";
const localPath = path.join(mediaDir, `${randomUUID()}.${ext}`);
await writeFile(localPath, fetched.buffer);
return {
localPath,
contentType: fetched.contentType ?? "application/octet-stream",
originalUrl: url,
};
} catch (error: unknown) {
console.error(
`[tlon-media] Error downloading ${url}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
function getExtensionFromFileName(fileName?: string): string | null {
if (!fileName) {
return null;
}
const ext = path.extname(fileName).replace(/^\./, "");
return ext || null;
}
function getExtensionFromContentType(contentType: string): string | null {
const map: Record<string, string> = {
"image/jpeg": "jpg",