refactor(media): collapse attachment byte classification into media-core (#122168)

Attachment MIME gating and extraction dispatch used nine separate byte policies across five files; the allowlist could approve one MIME while dispatch observed another after a second sniff. @openclaw/media-core/attachment-classify now owns byte sniffing, text heuristics, extension mapping, and the seven attachment classes; the cache stores one classification that gates and dispatch share. Channel-declared MIME leads with the transport Content-Type as a byte-arbitrated hint, normalizeMimeType folds YAML/XML synonyms so existing configured allowlists keep matching, and input_file API callers keep declared-MIME precedence over cosmetic filenames. Net -135 production LOC.

Proof: 2,153 focused tests across media-core/media-understanding/media plus boundary-contract and deadcode-mirror suites; ClawSweeper local review clean (round 5, zero findings); live Telegram default-turn probe green.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-12 00:40:49 +05:30
committed by GitHub
parent 2e6c693177
commit 746a188c90
36 changed files with 466 additions and 395 deletions
+1
View File
@@ -602,6 +602,7 @@ const config = {
"packages/media-core": {
entry: [
"src/index.ts!",
"src/attachment-classify.ts!",
"src/base64.ts!",
"src/constants.ts!",
"src/content-length.ts!",
@@ -1 +1 @@
{"contentHash":"e93bb5a79e7a848c462ec88f8060fc87829dcd063da2c9698c27e19129754afc","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"5a9d78a04eff220d33c4d2b7230c25f3d846ccddb4f2165967cf00fb948affa1","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"622cc8a24de6f41ff9d514399b034b7884d4f2e0f6f2bf75136640c80b1d8186","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"8aef71ef2bd4a5984c9a0ddea4c4f1e6d86b9bf74504b50f8ec6a25ad65401cb","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"edc6c7ca91050af6dfceb136b3887f41548707f48115ed8c0ee34d097f6e524f","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"df88650d813f6efbdffc5570647f183e69fa3015929c748b03e8b22bf0d916e6","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"4b3acf9c914456173c538f6635a95d797b96a1238eaab20314cd3b4c17809c23","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"d11b2d65878b0886a3dc102c99a74fcd5e9bb80f27809ee810812f26ddde73cf","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"a9c548d441e74fe5c60af72f3225ff71ad75ba503554c7496ed174d9162d7f43","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"3059df01f7a73ba9c511ce97a0ef358fd8109389f524c0a3914984745aa02db5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"eb36acbe0e79d92d2acc8545bafb3efb998a73c6c198b0f89449a497a85448ca","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"6fcaaa319bccd859ecbbe26278d3476ced29bdfed4e3ea0f05947c99d0e79bba","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"ecd4319e3dd1c8b390bc41546d784a99c1e640d3fed8d46ee041b2396f6b2538","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"2d253351db6cb80191067f6ce1aefba74d35dbbf1816c2741078e91738d608d7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"028ba363143c607ec08a4343df43d00cd6eee9a75aa5a56b1f3a8bef0f3e639f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"b9d274360639f0589b6036ba6d735d375a66edfe3f25fac3add96f5989c8bf8a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a9fedd2f64413f0f04751fc7551dbedb18e95573cf1bca3801de7defdf11a903","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"5fb15ccec47ac420b0a1fb6d0769faeff1e14f3da1905c3c3a29c24887f1aaf1","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"db3caebf92c46468b996948e3a0ed6efeca1957a77704719fbf6bd81fc07095a","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"8c220f857b5144582c560810b63948b14ba87524eff0aa7407d39ccb7df7de8f","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"10d52d83824b4b410912572ff36f135297fbeb8bb96328928fd30728e3f4546e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"f7dd2a2df426b3826fb9bf31a062004801675eb89c006508a91cd5f7713a739b","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f1026c641b4a60fc6743fa07bced87548bf93ab1a7ac52076dde31e7ce6abe33","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"6c93d74605b93456c5124fc15a3bb99d1a68734b9772a0514af3ded6fb647001","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f6370f0532670140d2a80069c39f492f62d2cf79f6f0245635e03492c0570131","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"f951f78a1ed81bc8c9eac5d2421e243abf28aac9297d21b2c174e22ce3fac045","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"5436af0315589c464d451a2cfdca71dc52b79a0906580c6656ebddb790792e48","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"1291d3c53625156807274d0ddc752b3ae0b01bf937f0fdca504a879698d8194a","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"98be777b4b8309d6f8ff70db300b312482170eda016b93b94430ed137e932557","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"c4ad78f6f23d67778f2d37cd7492e2c21832bd2622f56fa17d16f9b12fb1e955","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"54038626c9146bfc9bc4681fb345c39ab7de151b78c901fa125109c596cfd665","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"2d76db26f7ce4188386d49fd13ed7daf5185cb22a6d2ac1fd9ade7288b6c7d0f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -722,6 +722,9 @@
"@openclaw/media-core": [
"../dist/plugin-sdk/packages/media-core/src/index.d.ts"
],
"@openclaw/media-core/attachment-classify": [
"../dist/plugin-sdk/packages/media-core/src/attachment-classify.d.ts"
],
"@openclaw/media-core/base64": [
"../dist/plugin-sdk/packages/media-core/src/base64.d.ts"
],
+3
View File
@@ -701,6 +701,9 @@
"@openclaw/media-core": [
"../../dist/plugin-sdk/packages/media-core/src/index.d.ts"
],
"@openclaw/media-core/attachment-classify": [
"../../dist/plugin-sdk/packages/media-core/src/attachment-classify.d.ts"
],
"@openclaw/media-core/base64": [
"../../dist/plugin-sdk/packages/media-core/src/base64.d.ts"
],
+6 -1
View File
@@ -19,6 +19,11 @@
"import": "./dist/base64.mjs",
"default": "./dist/base64.mjs"
},
"./attachment-classify": {
"types": "./dist/attachment-classify.d.mts",
"import": "./dist/attachment-classify.mjs",
"default": "./dist/attachment-classify.mjs"
},
"./constants": {
"types": "./dist/constants.d.mts",
"import": "./dist/constants.mjs",
@@ -65,6 +70,6 @@
"file-type": "22.0.1"
},
"scripts": {
"build": "tsdown src/index.ts src/base64.ts src/constants.ts src/content-length.ts src/file-name.ts src/inbound-path-policy.ts src/inline-image-data-url.ts src/media-source-url.ts src/mime.ts src/read-byte-stream-with-limit.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/attachment-classify.ts src/base64.ts src/constants.ts src/content-length.ts src/file-name.ts src/inbound-path-policy.ts src/inline-image-data-url.ts src/media-source-url.ts src/mime.ts src/read-byte-stream-with-limit.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { attachmentClassFromMime, classifyAttachmentBytes } from "./attachment-classify.js";
import { normalizeMimeType } from "./mime.js";
describe("attachmentClassFromMime", () => {
it.each([
["text/plain", "text"],
["application/vnd.api+json", "text"],
["application/pdf", "document"],
["application/msword", "document"],
["image/png", "image"],
["audio/mpeg", "audio"],
["video/mp4", "video"],
["application/zip", "archive"],
["application/octet-stream", "binary"],
] as const)("classifies %s as %s", (mime, expected) => {
expect(attachmentClassFromMime(mime)).toBe(expected);
});
});
describe("classifyAttachmentBytes", () => {
it("infers delimited text from otherwise untyped bytes", async () => {
await expect(
classifyAttachmentBytes({ buffer: Buffer.from("name,value\nopenclaw,1"), name: "data.bin" }),
).resolves.toEqual({ mime: "text/csv", class: "text" });
});
it("returns the UTF-16 charset with text classification", async () => {
await expect(
classifyAttachmentBytes({
buffer: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]),
name: "notes.bin",
}),
).resolves.toEqual({ mime: "text/plain", class: "text", charset: "utf-16le" });
});
it("keeps the charset when a BOM-less UTF-16 file resolves text by extension", async () => {
await expect(
classifyAttachmentBytes({
buffer: Buffer.from("meeting notes for tomorrow", "utf16le"),
name: "notes.txt",
}),
).resolves.toEqual({ mime: "text/plain", class: "text", charset: "utf-16le" });
});
it("keeps byte-detected media ahead of a text filename", async () => {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=",
"base64",
);
await expect(classifyAttachmentBytes({ buffer: png, name: "spoof.txt" })).resolves.toEqual({
mime: "image/png",
class: "image",
});
});
it("does not let a text filename override ZIP bytes", async () => {
await expect(
classifyAttachmentBytes({ buffer: Buffer.from("PK\u0003\u0004payload"), name: "spoof.txt" }),
).resolves.toEqual({ mime: "application/zip", class: "archive" });
});
it("keeps declared octet-stream content binary without a text extension", async () => {
await expect(
classifyAttachmentBytes({
buffer: Buffer.from("printable but explicitly binary"),
declaredMime: "application/octet-stream",
name: "payload.bin",
}),
).resolves.toEqual({ mime: "application/octet-stream", class: "binary" });
});
it.each([
["config.yaml", "application/yaml"],
["payload.xml", "text/xml"],
["debug.log", "text/plain"],
["settings.ini", "text/plain"],
] as const)("uses the canonical extension MIME for %s", async (name, mime) => {
await expect(
classifyAttachmentBytes({ buffer: Buffer.from("key=value"), name }),
).resolves.toEqual({ mime, class: "text" });
});
});
describe("mime synonym folding", () => {
it("matches a configured text/yaml allowlist against classified .yaml files", async () => {
const classified = await classifyAttachmentBytes({
buffer: Buffer.from("key: value\nitems:\n - one\n", "utf8"),
name: "config.yaml",
});
expect(classified.mime).toBe("application/yaml");
expect(normalizeMimeType("text/yaml")).toBe(classified.mime);
expect(normalizeMimeType("application/xml")).toBe("text/xml");
});
});
@@ -0,0 +1,163 @@
import { detectMime, isZipContainerMime, mimeTypeFromFilePath, normalizeMimeType } from "./mime.js";
export type AttachmentClass =
| "text"
| "document"
| "image"
| "audio"
| "video"
| "archive"
| "binary";
type AttachmentCharset = "utf-16le" | "utf-16be";
export type AttachmentClassification = {
mime: string | undefined;
class: AttachmentClass;
charset?: AttachmentCharset;
};
const TEXT_APPLICATION_MIME = /^application\/(?:json|javascript|xml|yaml|x-yaml)$/;
const DOCUMENT_MIME =
/^application\/(?:pdf|msword|x-cfb|vnd\.(?:apple\.(?:keynote|numbers|pages)|ms-.+|oasis\.opendocument\..+|openxmlformats-officedocument\..+))$/;
const ARCHIVE_MIME =
/^application\/(?:gzip|vnd\.rar|x-7z-compressed|x-gzip|x-rar-compressed|x-tar|x-zip-compressed|zip)$/;
const WORDISH_CHAR = /[\p{L}\p{N}]/u;
export function attachmentClassFromMime(mime?: string | null): AttachmentClass {
const normalized = normalizeMimeType(mime);
if (!normalized) {
return "binary";
}
if (
normalized.startsWith("text/") ||
TEXT_APPLICATION_MIME.test(normalized) ||
normalized.endsWith("+json") ||
normalized.endsWith("+xml")
) {
return "text";
}
if (normalized.startsWith("image/")) {
return "image";
}
if (normalized.startsWith("audio/")) {
return "audio";
}
if (normalized.startsWith("video/")) {
return "video";
}
if (DOCUMENT_MIME.test(normalized)) {
return "document";
}
return ARCHIVE_MIME.test(normalized) || isZipContainerMime(normalized) ? "archive" : "binary";
}
function resolveUtf16Charset(buffer: Buffer): AttachmentCharset | undefined {
if (buffer.length < 2) {
return undefined;
}
const bom = buffer.readUInt16LE(0);
if (bom === 0xfeff) {
return "utf-16le";
}
if (bom === 0xfffe) {
return "utf-16be";
}
const sampleLength = Math.min(buffer.length, 2048);
let zeroEven = 0;
let zeroOdd = 0;
for (let index = 0; index < sampleLength; index += 1) {
if (buffer[index] === 0) {
if (index % 2 === 0) {
zeroEven += 1;
} else {
zeroOdd += 1;
}
}
}
if ((zeroEven + zeroOdd) / sampleLength <= 0.2) {
return undefined;
}
return zeroOdd >= zeroEven ? "utf-16le" : "utf-16be";
}
function textRatios(text: string): [printable: number, wordish: number] {
let printable = 0;
let control = 0;
let wordish = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code === 9 || code === 10 || code === 13 || code === 32) {
printable += 1;
wordish += 1;
} else if (code < 32 || (code >= 0x7f && code <= 0x9f)) {
control += 1;
} else {
printable += 1;
wordish += Number(WORDISH_CHAR.test(char));
}
}
const total = printable + control;
return total === 0 ? [0, 0] : [printable / total, wordish / total];
}
function looksLikeText(buffer: Buffer): boolean {
if (buffer.length === 0) {
return false;
}
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
try {
return textRatios(new TextDecoder("utf-8", { fatal: true }).decode(sample))[0] > 0.85;
} catch {
const [printable, wordish] = textRatios(new TextDecoder("windows-1252").decode(sample));
return printable > 0.95 && wordish > 0.3;
}
}
export async function classifyAttachmentBytes(params: {
buffer: Buffer;
declaredMime?: string | null;
/** Ordered fallback hints (e.g. transport Content-Type); bytes arbitrate. */
additionalMimeHints?: readonly (string | null | undefined)[];
name?: string | null;
}): Promise<AttachmentClassification> {
const mime = await detectMime({
buffer: params.buffer,
headerMime: params.declaredMime,
additionalMimeHints: params.additionalMimeHints,
filePath: params.name ?? undefined,
});
const detectedClass = attachmentClassFromMime(mime);
const charset = resolveUtf16Charset(params.buffer);
const hasUtf16Bom =
params.buffer.length >= 2 &&
(params.buffer.readUInt16LE(0) === 0xfeff || params.buffer.readUInt16LE(0) === 0xfffe);
if (
mime === "application/octet-stream" ||
mime?.startsWith("application/vnd.") ||
(detectedClass !== "binary" && !hasUtf16Bom)
) {
// Text resolved by extension can still be BOM-less UTF-16; dropping the
// detected charset here would decode it downstream as UTF-8 mojibake.
return detectedClass === "text" && charset
? { mime, class: detectedClass, charset }
: { mime, class: detectedClass };
}
const signature = params.buffer.length >= 4 ? params.buffer.readUInt32BE(0) : 0;
if (signature === 0x504b0304 || signature === 0x504b0102 || signature === 0x504b0506) {
return { mime, class: "archive" };
}
if (!charset && !looksLikeText(params.buffer)) {
return { mime, class: "binary" };
}
const extensionMime = mimeTypeFromFilePath(params.name);
const firstLine = new TextDecoder(charset ?? "utf-8")
.decode(params.buffer.subarray(0, Math.min(params.buffer.length, 8192)))
.split(/\r?\n/, 1)[0];
const textMime =
(attachmentClassFromMime(extensionMime) === "text" ? extensionMime : undefined) ??
(firstLine?.includes(",")
? "text/csv"
: firstLine?.includes("\t")
? "text/tab-separated-values"
: "text/plain");
return { mime: textMime, class: "text", ...(charset ? { charset } : {}) };
}
+1
View File
@@ -1,6 +1,7 @@
// Public barrel for media URL, MIME, path, and byte-stream helpers.
export * from "./base64.js";
export * from "./attachment-classify.js";
export * from "./constants.js";
export * from "./content-length.js";
export * from "./file-name.js";
+20 -5
View File
@@ -90,9 +90,14 @@ const MIME_BY_EXT: Record<string, string> = {
".aif": "audio/aiff",
".aifc": "audio/aiff",
".jpeg": "image/jpeg",
".cfg": "text/plain",
".conf": "text/plain",
".env": "text/plain",
".ini": "text/plain",
".js": "text/javascript",
".log": "text/plain",
".htm": "text/html",
".tsv": "text/tab-separated-values",
".xml": "text/xml",
".yml": "application/yaml",
};
@@ -145,20 +150,30 @@ const ZIP_CONTAINER_MIMES = new Set([
"model/3mf",
]);
function isZipContainerMime(mime: string): boolean {
export function isZipContainerMime(mime: string): boolean {
return mime.endsWith("+zip") || ZIP_CONTAINER_MIMES.has(mime);
}
/** Normalizes MIME strings by dropping parameters, lowercasing, and folding APNG to PNG. */
// Registered/legacy synonym pairs fold to one canonical spelling so configured
// allowlists and byte classification always compare the same value; without
// this an operator's existing text/yaml allowlist stops matching .yaml files.
const MIME_SYNONYMS: Record<string, string> = {
"image/apng": "image/png",
"text/yaml": "application/yaml",
"application/x-yaml": "application/yaml",
"application/xml": "text/xml",
};
/** Normalizes MIME strings by dropping parameters, lowercasing, and folding registered synonyms. */
export function normalizeMimeType(mime?: string | null): string | undefined {
if (!mime) {
return undefined;
}
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
if (cleaned === "image/apng") {
return "image/png";
if (!cleaned) {
return undefined;
}
return cleaned || undefined;
return MIME_SYNONYMS[cleaned] ?? cleaned;
}
/** Returns the bounded buffer prefix used for dependency MIME sniffing. */
@@ -158,6 +158,9 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"../dist/plugin-sdk/packages/media-generation-core/src/*.d.ts",
],
"@openclaw/media-core": ["../dist/plugin-sdk/packages/media-core/src/index.d.ts"],
"@openclaw/media-core/attachment-classify": [
"../dist/plugin-sdk/packages/media-core/src/attachment-classify.d.ts",
],
"@openclaw/media-core/base64": ["../dist/plugin-sdk/packages/media-core/src/base64.d.ts"],
"@openclaw/media-core/constants": ["../dist/plugin-sdk/packages/media-core/src/constants.d.ts"],
"@openclaw/media-core/content-length": [
@@ -940,7 +940,7 @@ describe("sessions.files RPC handlers", () => {
it.each([
{ format: "RTF", mimeType: "application/rtf", content: "{\\rtf1\\ansi hello}" },
{ format: "XML", mimeType: "application/xml", content: '<?xml version="1.0"?><root/>' },
{ format: "XML", mimeType: "text/xml", content: '<?xml version="1.0"?><root/>' },
{ format: "WebVTT", mimeType: "text/vtt", content: "WEBVTT\n\n00:00.000 --> 00:01.000\nHi" },
{ format: "vCard", mimeType: "text/vcard", content: "BEGIN:VCARD\nVERSION:4.0\nEND:VCARD\n" },
{
+26 -118
View File
@@ -1,10 +1,11 @@
// Applies media-understanding outputs to inbound message context, including
// attachment normalization, provider execution, file text extraction, and echoing.
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
attachmentClassFromMime,
type AttachmentClassification,
} from "@openclaw/media-core/attachment-classify";
import { mimeTypeFromFilePath, normalizeMimeType } from "@openclaw/media-core/mime";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import pMap from "p-map";
import type { ActiveMediaModel } from "../../packages/media-understanding-common/src/active-model.js";
import {
@@ -16,16 +17,9 @@ import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { renderFileContextBlock } from "../media/file-context.js";
import { extractFileContentFromSource, normalizeMimeType } from "../media/input-files.js";
import { extractFileContentFromSource } from "../media/input-files.js";
import { classifyMediaReferenceSource } from "../media/media-reference.js";
import { runMediaCapability } from "./apply-capability.js";
import {
decodeTextSample,
guessDelimitedMime,
hasSuspiciousBinarySignal,
looksLikeUtf8Text,
resolveUtf16Charset,
} from "./attachment-text-sniff.js";
import { resolveAttachmentKind } from "./attachments.js";
import { DEFAULT_ECHO_TRANSCRIPT_FORMAT, sendTranscriptEcho } from "./echo-transcript.js";
import type { ExtractedFileImage } from "./extracted-file-images.js";
@@ -73,31 +67,6 @@ const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "vid
const AUDIO_ONLY_CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["audio"];
const EMPTY_VOICE_NOTE_PLACEHOLDER =
"[Voice note could not be transcribed because the audio attachment was too small]";
const EXTRA_TEXT_MIMES = [
"application/xml",
"text/xml",
"application/x-yaml",
"text/yaml",
"application/yaml",
"application/javascript",
"text/javascript",
"text/tab-separated-values",
];
const TEXT_EXT_MIME = new Map<string, string>([
[".csv", "text/csv"],
[".tsv", "text/tab-separated-values"],
[".txt", "text/plain"],
[".md", "text/markdown"],
[".log", "text/plain"],
[".ini", "text/plain"],
[".cfg", "text/plain"],
[".conf", "text/plain"],
[".env", "text/plain"],
[".json", "application/json"],
[".yaml", "text/yaml"],
[".yml", "text/yaml"],
[".xml", "application/xml"],
]);
function appendFileBlocks(body: string | undefined, blocks: string[]): string {
if (!blocks || blocks.length === 0) {
@@ -111,14 +80,6 @@ function appendFileBlocks(body: string | undefined, blocks: string[]): string {
return `${base}\n\n${suffix}`.trim();
}
function resolveTextMimeFromName(name?: string): string | undefined {
if (!name) {
return undefined;
}
const ext = normalizeLowercaseStringOrEmpty(path.extname(name));
return TEXT_EXT_MIME.get(ext);
}
function buildSyntheticSkippedAudioOutputs(
decisions: MediaUnderstandingDecision[],
): MediaUnderstandingOutput[] {
@@ -145,42 +106,6 @@ function buildSyntheticSkippedAudioOutputs(
});
}
function isBinaryMediaMime(mime?: string): boolean {
if (!mime) {
return false;
}
if (mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/")) {
return true;
}
if (mime === "application/octet-stream") {
return true;
}
if (
mime === "application/zip" ||
mime === "application/x-zip-compressed" ||
mime === "application/gzip" ||
mime === "application/x-gzip" ||
mime === "application/x-rar-compressed" ||
mime === "application/x-7z-compressed" ||
mime === "application/msword" ||
mime === "application/x-cfb"
) {
return true;
}
if (mime.endsWith("+zip")) {
return true;
}
if (mime.startsWith("application/vnd.")) {
// Keep vendor +json/+xml payloads eligible for text extraction while
// treating the common binary vendor family (Office, archives, etc.) as binary.
if (mime.endsWith("+json") || mime.endsWith("+xml")) {
return false;
}
return true;
}
return false;
}
type ClassifiedFileAttachment = {
outcome: FileAttachmentOutcome;
filename?: string;
@@ -213,7 +138,9 @@ async function classifyFileAttachment(params: {
if (skipAttachmentIndexes?.has(attachment.index)) {
return { outcome: { kind: "claimed-elsewhere" } };
}
const forcedTextMime = resolveTextMimeFromName(attachmentFilename ?? "");
const extensionMime = mimeTypeFromFilePath(attachmentFilename);
const forcedTextMime =
attachmentClassFromMime(extensionMime) === "text" ? extensionMime : undefined;
const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment);
if (!forcedTextMime && (kind === "image" || kind === "video" || kind === "audio")) {
return { outcome: { kind: "claimed-elsewhere" } };
@@ -243,45 +170,31 @@ async function classifyFileAttachment(params: {
return { outcome: { kind: "read-failure" }, filename: attachmentFilename };
}
const filename = bufferResult?.fileName;
const nameHint = filename ?? attachmentFilename;
const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? "");
const rawMime = bufferResult?.mime ?? attachment.mime;
const normalizedRawMime = normalizeMimeType(rawMime);
const classification: AttachmentClassification = bufferResult.classification;
// Marker mime prefers the sender-declared type; never the name-forced text mime,
// which would mislabel binary bytes inside a text-named file as a text format.
// Both candidates pass strict token validation so raw header text never
// reaches model context; undefined drops the mime from block and marker.
const binaryMime =
sanitizeMimeType(normalizeMimeType(attachment.mime)) ?? sanitizeMimeType(normalizedRawMime);
if (!forcedTextMimeResolved && isBinaryMediaMime(normalizedRawMime)) {
sanitizeMimeType(normalizeMimeType(attachment.mime)) ?? sanitizeMimeType(classification.mime);
if (
classification.class !== "text" &&
!(classification.class === "document" && classification.mime === "application/pdf")
) {
return {
outcome: { kind: "unsupported-format", mime: binaryMime },
filename,
mimeType: binaryMime,
};
}
if (hasSuspiciousBinarySignal(bufferResult?.buffer)) {
return {
outcome: { kind: "unsupported-format", mime: binaryMime },
filename,
mimeType: binaryMime,
};
}
const utf16Charset = resolveUtf16Charset(bufferResult?.buffer);
const textSample = decodeTextSample(bufferResult?.buffer);
// Do not coerce real PDFs into text/plain via printable-byte heuristics.
// PDFs have a dedicated extraction path in extractFileContentFromSource.
const allowTextHeuristic = normalizedRawMime !== "application/pdf";
const textLike =
allowTextHeuristic && (Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer));
const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined;
const textHint =
forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
const mimeType = sanitizeMimeType(textHint ?? normalizeMimeType(rawMime));
// Log when MIME type is overridden from non-text to text for auditability
if (textHint && rawMime && !rawMime.startsWith("text/")) {
const mimeType = sanitizeMimeType(classification.mime);
if (
classification.class === "text" &&
attachment.mime &&
normalizeMimeType(attachment.mime) !== classification.mime
) {
logVerbose(
`media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`,
`media: MIME override from "${attachment.mime}" to "${classification.mime}" for index=${attachment.index}`,
);
}
if (!mimeType) {
@@ -291,13 +204,8 @@ async function classifyFileAttachment(params: {
return { outcome: { kind: "unsupported-format" }, filename };
}
const allowedMimes = new Set(limits.allowedMimes);
if (!limits.allowedMimesConfigured) {
for (const extra of EXTRA_TEXT_MIMES) {
allowedMimes.add(extra);
}
if (mimeType.startsWith("text/")) {
allowedMimes.add(mimeType);
}
if (!limits.allowedMimesConfigured && classification.class === "text") {
allowedMimes.add(mimeType);
}
if (!allowedMimes.has(mimeType)) {
if (shouldLogVerbose()) {
@@ -315,17 +223,17 @@ async function classifyFileAttachment(params: {
}
let extracted: Awaited<ReturnType<typeof extractFileContentFromSource>>;
try {
const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType;
const { allowedMimesConfigured: _allowedMimesConfigured, ...baseLimits } = limits;
extracted = await extractFileContentFromSource({
source: {
type: "base64",
data: bufferResult.buffer.toString("base64"),
mediaType,
mediaType: mimeType,
filename: bufferResult.fileName,
},
limits: { ...baseLimits, allowedMimes },
config: cfg,
classification,
});
} catch (err) {
if (shouldLogVerbose()) {
@@ -1,194 +0,0 @@
// Byte-level text detection for inbound attachments: charset resolution,
// legacy-codepage heuristics, archive signatures, and delimiter guessing.
import { expectDefined } from "@openclaw/normalization-core";
export function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined {
// Some chat attachments arrive as UTF-16 without a reliable MIME charset; the
// BOM and zero-byte distribution are enough to select a safe decoder.
if (!buffer || buffer.length < 2) {
return undefined;
}
const b0 = buffer[0];
const b1 = buffer[1];
if (b0 === 0xff && b1 === 0xfe) {
return "utf-16le";
}
if (b0 === 0xfe && b1 === 0xff) {
return "utf-16be";
}
const sampleLen = Math.min(buffer.length, 2048);
let zeroEven = 0;
let zeroOdd = 0;
for (let i = 0; i < sampleLen; i += 1) {
if (buffer[i] !== 0) {
continue;
}
if (i % 2 === 0) {
zeroEven += 1;
} else {
zeroOdd += 1;
}
}
const zeroCount = zeroEven + zeroOdd;
if (zeroCount / sampleLen > 0.2) {
return zeroOdd >= zeroEven ? "utf-16le" : "utf-16be";
}
return undefined;
}
const WORDISH_CHAR = /[\p{L}\p{N}]/u;
const CP1252_MAP: Array<string | undefined> = [
"\u20ac",
undefined,
"\u201a",
"\u0192",
"\u201e",
"\u2026",
"\u2020",
"\u2021",
"\u02c6",
"\u2030",
"\u0160",
"\u2039",
"\u0152",
undefined,
"\u017d",
undefined,
undefined,
"\u2018",
"\u2019",
"\u201c",
"\u201d",
"\u2022",
"\u2013",
"\u2014",
"\u02dc",
"\u2122",
"\u0161",
"\u203a",
"\u0153",
undefined,
"\u017e",
"\u0178",
];
function decodeLegacyText(buffer: Buffer): string {
let output = "";
for (const byte of buffer) {
if (byte >= 0x80 && byte <= 0x9f) {
const mapped = CP1252_MAP[byte - 0x80];
output += mapped ?? String.fromCharCode(byte);
continue;
}
output += String.fromCharCode(byte);
}
return output;
}
function getTextStats(text: string): { printableRatio: number; wordishRatio: number } {
if (!text) {
return { printableRatio: 0, wordishRatio: 0 };
}
let printable = 0;
let control = 0;
let wordish = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code === 9 || code === 10 || code === 13 || code === 32) {
printable += 1;
wordish += 1;
continue;
}
if (code < 32 || (code >= 0x7f && code <= 0x9f)) {
control += 1;
continue;
}
printable += 1;
if (WORDISH_CHAR.test(char)) {
wordish += 1;
}
}
const total = printable + control;
if (total === 0) {
return { printableRatio: 0, wordishRatio: 0 };
}
return { printableRatio: printable / total, wordishRatio: wordish / total };
}
function isMostlyPrintable(text: string): boolean {
return getTextStats(text).printableRatio > 0.85;
}
function looksLikeLegacyTextBytes(buffer: Buffer): boolean {
if (buffer.length === 0) {
return false;
}
const text = decodeLegacyText(buffer);
const { printableRatio, wordishRatio } = getTextStats(text);
return printableRatio > 0.95 && wordishRatio > 0.3;
}
export function looksLikeUtf8Text(buffer?: Buffer): boolean {
if (!buffer || buffer.length === 0) {
return false;
}
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(sample);
return isMostlyPrintable(text);
} catch {
return looksLikeLegacyTextBytes(sample);
}
}
export function hasSuspiciousBinarySignal(buffer?: Buffer): boolean {
if (!buffer || buffer.length === 0) {
return false;
}
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
if (sample.length < 4 || sample[0] !== 0x50 || sample[1] !== 0x4b) {
return false;
}
const signature =
(expectDefined(sample[2], "sample entry at 2") << 8) |
expectDefined(sample[3], "sample entry at 3");
// Cover the ZIP local-header, central-directory, and empty-archive markers
// so archive payloads cannot slip past text coercion when MIME detection is weak.
return signature === 0x0304 || signature === 0x0102 || signature === 0x0506;
}
export function decodeTextSample(buffer?: Buffer): string {
if (!buffer || buffer.length === 0) {
return "";
}
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
const utf16Charset = resolveUtf16Charset(sample);
if (utf16Charset === "utf-16be") {
const swapped = Buffer.alloc(sample.length);
for (let i = 0; i + 1 < sample.length; i += 2) {
swapped[i] = expectDefined(sample[i + 1], "UTF-16BE low byte");
swapped[i + 1] = expectDefined(sample[i], "UTF-16BE high byte");
}
return new TextDecoder("utf-16le").decode(swapped);
}
if (utf16Charset === "utf-16le") {
return new TextDecoder("utf-16le").decode(sample);
}
return new TextDecoder("utf-8").decode(sample);
}
export function guessDelimitedMime(text: string): string | undefined {
if (!text) {
return undefined;
}
const line = text.split(/\r?\n/)[0] ?? "";
const tabs = (line.match(/\t/g) ?? []).length;
const commas = (line.match(/,/g) ?? []).length;
if (commas > 0) {
return "text/csv";
}
if (tabs > 0) {
return "text/tab-separated-values";
}
return undefined;
}
+33 -34
View File
@@ -3,11 +3,14 @@
import { realpathSync, statSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
classifyAttachmentBytes,
type AttachmentClassification,
} from "@openclaw/media-core/attachment-classify";
import {
isInboundPathAllowed,
mergeInboundPathRoots,
} from "@openclaw/media-core/inbound-path-policy";
import { detectMime } from "@openclaw/media-core/mime";
import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js";
import { resolveStateDir } from "../config/paths.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
@@ -31,6 +34,7 @@ import type { MediaAttachment } from "./types.js";
type MediaBufferResult = {
buffer: Buffer;
classification: AttachmentClassification;
mime?: string;
fileName: string;
size: number;
@@ -57,9 +61,7 @@ type AttachmentCacheEntry = {
attachment: MediaAttachment;
resolvedPath?: string;
statSize?: number;
buffer?: Buffer;
bufferMime?: string;
bufferFileName?: string;
bufferResult?: MediaBufferResult;
tempPath?: string;
tempCleanup?: () => Promise<void>;
localResolutionAttempted?: boolean;
@@ -83,7 +85,9 @@ function remoteFetchUrl(url: string | undefined): string | undefined {
function concreteMime(mime: string | undefined): string | undefined {
const normalized = mime?.trim();
if (!normalized || normalized.endsWith("/*")) {
// octet-stream is a non-answer, not a concrete type; a generic download
// header must not shadow the attachment fact's declared MIME.
if (!normalized || normalized.endsWith("/*") || normalized === "application/octet-stream") {
return undefined;
}
return normalized;
@@ -164,19 +168,14 @@ export class MediaAttachmentCache {
}): Promise<MediaBufferResult> {
const entry = await this.ensureEntry(params.attachmentIndex);
const url = remoteFetchUrl(entry.attachment.url);
if (entry.buffer) {
if (entry.buffer.length > params.maxBytes) {
if (entry.bufferResult) {
if (entry.bufferResult.size > params.maxBytes) {
throw new MediaUnderstandingSkipError(
"maxBytes",
`Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`,
);
}
return {
buffer: entry.buffer,
mime: entry.bufferMime,
fileName: entry.bufferFileName ?? `media-${params.attachmentIndex + 1}`,
size: entry.buffer.length,
};
return entry.bufferResult;
}
if (entry.resolvedPath) {
@@ -223,20 +222,22 @@ export class MediaAttachmentCache {
ssrfPolicy: this.ssrfPolicy,
retry: REMOTE_MEDIA_FETCH_RETRY,
});
entry.buffer = fetched.buffer;
entry.bufferMime = await detectMime({
const classification = await classifyAttachmentBytes({
buffer: fetched.buffer,
filePath: fetched.fileName ?? url,
headerMime: concreteMime(entry.attachment.mime),
name: fetched.fileName ?? url,
// Channel-declared MIME leads; the transport Content-Type stays a
// secondary hint so byte detection can refine stale declarations.
declaredMime: concreteMime(entry.attachment.mime),
additionalMimeHints: [fetched.contentType],
});
entry.bufferFileName = fetched.fileName ?? `media-${params.attachmentIndex + 1}`;
return {
entry.bufferResult = {
buffer: fetched.buffer,
mime: entry.bufferMime,
fileName: entry.bufferFileName,
classification,
mime: classification.mime,
fileName: fetched.fileName ?? `media-${params.attachmentIndex + 1}`,
size: fetched.buffer.length,
};
return entry.bufferResult;
} catch (err) {
if (err instanceof MediaFetchError && err.code === "max_bytes") {
throw new MediaUnderstandingSkipError(
@@ -275,21 +276,19 @@ export class MediaAttachmentCache {
maxBytes: params.maxBytes,
});
entry.resolvedPath = filePath;
entry.buffer = buffer;
entry.bufferMime =
entry.bufferMime ??
(await detectMime({
buffer,
filePath,
headerMime: concreteMime(entry.attachment.mime),
}));
entry.bufferFileName = path.basename(filePath) || `media-${params.attachmentIndex + 1}`;
return {
const classification = await classifyAttachmentBytes({
buffer,
mime: entry.bufferMime,
fileName: entry.bufferFileName,
name: filePath,
declaredMime: concreteMime(entry.attachment.mime),
});
entry.bufferResult = {
buffer,
classification,
mime: classification.mime,
fileName: path.basename(filePath) || `media-${params.attachmentIndex + 1}`,
size: buffer.length,
};
return entry.bufferResult;
}
private recordRecoverableLocalError(entry: AttachmentCacheEntry, err: unknown): boolean {
@@ -367,7 +366,7 @@ export class MediaAttachmentCache {
}
if (entry.tempPath) {
if (params.maxBytes && entry.buffer && entry.buffer.length > params.maxBytes) {
if (params.maxBytes && entry.bufferResult && entry.bufferResult.size > params.maxBytes) {
throw new MediaUnderstandingSkipError(
"maxBytes",
`Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`,
@@ -1,6 +1,7 @@
// Image input normalization converts HEIC/HEIF payloads through the shared
// input-file media path before provider execution.
import { extractImageContentFromSource, normalizeMimeType } from "../media/input-files.js";
import { normalizeMimeType } from "@openclaw/media-core/mime";
import { extractImageContentFromSource } from "../media/input-files.js";
import { DEFAULT_MAX_BYTES } from "./defaults.constants.js";
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
@@ -110,6 +110,7 @@ describe("media understanding attachments SSRF", () => {
});
expect(result).toStrictEqual({
buffer: Buffer.from("image"),
classification: { mime: "image/jpeg", class: "image" },
mime: "image/jpeg",
fileName: "file.jpg",
size: 5,
@@ -139,6 +140,53 @@ describe("media understanding attachments SSRF", () => {
expect(result.fileName).toBe("image.png");
});
it("keeps the attachment's concrete MIME when the download reports octet-stream", async () => {
const url = "http://198.18.0.153/export";
const fetchSpy = vi.fn().mockResolvedValue(
new Response('{"report":"q3"}', {
headers: { "content-type": "application/octet-stream" },
}),
);
globalThis.fetch = withFetchPreconnect(fetchSpy);
const cache = new MediaAttachmentCache([{ index: 0, url, mime: "application/json" }], {
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
const result = await cache.getBuffer({
attachmentIndex: 0,
maxBytes: 1024,
timeoutMs: 1000,
});
// A generic download header is a non-answer; the declared concrete type
// must survive so allowlists and extraction see application/json.
expect(result.classification.mime).toBe("application/json");
expect(result.classification.class).toBe("text");
});
it("prefers the channel-declared MIME over a generic fetched text header", async () => {
const url = "http://198.18.0.153/export.data";
const fetchSpy = vi.fn().mockResolvedValue(
new Response('{"report":"q4"}', {
headers: { "content-type": "text/plain" },
}),
);
globalThis.fetch = withFetchPreconnect(fetchSpy);
const cache = new MediaAttachmentCache([{ index: 0, url, mime: "application/json" }], {
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
const result = await cache.getBuffer({
attachmentIndex: 0,
maxBytes: 1024,
timeoutMs: 1000,
});
// Channel declaration outranks transport Content-Type: a JSON-only
// allowlist must keep matching when a proxy relabels the download.
expect(result.classification.mime).toBe("application/json");
});
it("reads local attachments inside configured roots", async () => {
await withLocalAttachmentCache("openclaw-media-cache-allowed-", async ({ cache }) => {
const result = await cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 });
+23
View File
@@ -0,0 +1,23 @@
// Regression: input_file callers declare their MIME; a cosmetic filename must
// not reroute classification past an operator-configured allowlist.
import { describe, expect, it } from "vitest";
import { extractFileContentFromSource, resolveInputFileLimits } from "./input-files.js";
describe("extractFileContentFromSource", () => {
it("keeps the declared MIME when the filename suggests plain text", async () => {
const payload = JSON.stringify({ report: "q3", revenue: 12345 });
const limits = resolveInputFileLimits({ allowedMimes: ["application/json"] });
const result = await extractFileContentFromSource({
source: {
type: "base64",
data: Buffer.from(payload, "utf8").toString("base64"),
mediaType: "application/json",
filename: "notes.txt",
},
limits,
});
expect(result.text).toContain('"revenue"');
});
});
+6 -3
View File
@@ -14,9 +14,12 @@ vi.mock("./media-services.js", () => ({
convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args),
}));
vi.mock("@openclaw/media-core/mime", () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
}));
vi.mock("@openclaw/media-core/mime", async () => {
const actual = await vi.importActual<typeof import("@openclaw/media-core/mime")>(
"@openclaw/media-core/mime",
);
return { ...actual, detectMime: (...args: unknown[]) => detectMimeMock(...args) };
});
vi.mock("./pdf-extract.js", () => ({
extractPdfContent: (...args: unknown[]) => extractPdfContentMock(...args),
+12 -22
View File
@@ -1,7 +1,11 @@
// Input file helpers normalize inline, fetched, and local media inputs.
import {
classifyAttachmentBytes,
type AttachmentClassification,
} from "@openclaw/media-core/attachment-classify";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { parseMediaContentLength } from "@openclaw/media-core/content-length";
import { detectMime } from "@openclaw/media-core/mime";
import { detectMime, normalizeMimeType } from "@openclaw/media-core/mime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -155,12 +159,6 @@ function rejectOversizedBase64Payload(params: {
}
}
/** Normalizes a MIME value by stripping parameters and lowercasing the media type. */
export function normalizeMimeType(value: string | undefined): string | undefined {
const [raw] = value?.split(";") ?? [];
return normalizeOptionalLowercaseString(raw);
}
/** Parses a Content-Type header into normalized MIME and optional charset values. */
function parseContentType(value: string | undefined): {
mimeType?: string;
@@ -335,20 +333,6 @@ async function normalizeInputImage(params: {
};
}
async function resolveInputFileMime(params: {
buffer: Buffer;
declaredMime?: string;
}): Promise<string | undefined> {
const sniffedMime = normalizeMimeType(await detectMime({ buffer: params.buffer }));
if (!sniffedMime) {
return params.declaredMime;
}
if (sniffedMime === "application/octet-stream") {
return params.declaredMime ?? sniffedMime;
}
return sniffedMime;
}
/** Extracts and normalizes an input_image source from base64 or guarded URL input. */
export async function extractImageContentFromSource(
source: InputImageSource,
@@ -403,6 +387,7 @@ export async function extractFileContentFromSource(params: {
source: InputFileSource;
limits: InputFileLimits;
config?: OpenClawConfig;
classification?: AttachmentClassification;
}): Promise<InputFileExtractResult> {
const { source, limits } = params;
const filename = source.filename || "file";
@@ -446,7 +431,12 @@ export async function extractFileContentFromSource(params: {
throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`);
}
mimeType = await resolveInputFileMime({ buffer, declaredMime: mimeType });
// Direct input_file callers declare their content type; the filename is
// display metadata and must not override an explicitly allowlisted MIME.
const classification =
params.classification ?? (await classifyAttachmentBytes({ buffer, declaredMime: mimeType }));
mimeType = classification.mime;
charset = classification.charset ?? charset;
if (!mimeType) {
throw new Error("input_file missing media type");
+1
View File
@@ -546,6 +546,7 @@ export const sharedVitestConfig = {
},
sourcePackageAlias("markdown-core", "code-spans"),
sourcePackageAlias("markdown-core", "fences"),
sourcePackageAlias("media-core", "attachment-classify"),
sourcePackageAlias("media-core", "base64"),
sourcePackageAlias("media-core", "constants"),
sourcePackageAlias("media-core", "content-length"),
+3
View File
@@ -108,6 +108,9 @@
],
"@openclaw/media-generation-core/*": ["./packages/media-generation-core/src/*"],
"@openclaw/media-core": ["./packages/media-core/src/index.ts"],
"@openclaw/media-core/attachment-classify": [
"./packages/media-core/src/attachment-classify.ts"
],
"@openclaw/media-core/base64": ["./packages/media-core/src/base64.ts"],
"@openclaw/media-core/constants": ["./packages/media-core/src/constants.ts"],
"@openclaw/media-core/content-length": ["./packages/media-core/src/content-length.ts"],