mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
* fix: openclaw allows normal reply text to carry media (#345)
This commit is contained in:
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
|
||||
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
|
||||
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
|
||||
- Auto-reply/media: block arbitrary absolute host-local `MEDIA:` paths from normal reply text, keep managed generated-media reply paths working, and require detected PDF or Office file content before host-local document sends are accepted.
|
||||
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
|
||||
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
|
||||
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
beforeEach(() => {
|
||||
ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null);
|
||||
saveMediaSource.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("resolves workspace-relative media against the agent workspace", async () => {
|
||||
@@ -61,7 +62,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps host-local media paths flexible when sandbox exists and workspaceOnly is off", async () => {
|
||||
it("drops arbitrary host-local media paths when sandbox exists", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -77,13 +78,13 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/Users/peter/.openclaw/media/inbound/photo.png",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps sandbox media strict when workspaceOnly is enabled", async () => {
|
||||
it("drops relative sandbox escapes when tools.fs.workspaceOnly is enabled", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -94,11 +95,59 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
}),
|
||||
).rejects.toThrow(/sandbox root|outside|escapes/i);
|
||||
const result = await normalize({
|
||||
mediaUrls: ["../sandboxes/session-1/screens/final.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps managed generated media under the shared media root", async () => {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/Users/peter/.openclaw/media/tool-image-generation/generated.png",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops absolute file URLs outside managed reply media roots", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["file:///Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists volatile agent-state media from the workspace into host outbound media", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { saveMediaSource } from "../../media/store.js";
|
||||
import { resolveConfigDir } from "../../utils.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
@@ -16,12 +17,37 @@ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
|
||||
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
||||
const HAS_FILE_EXT_RE = /\.\w{1,10}$/;
|
||||
const AGENT_STATE_MEDIA_DIRNAME = path.join(".openclaw", "media");
|
||||
const MANAGED_GLOBAL_MEDIA_SUBDIRS = new Set(["outbound"]);
|
||||
|
||||
function isPathInside(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isManagedGlobalReplyMediaPath(candidate: string): boolean {
|
||||
const globalMediaRoot = path.join(resolveConfigDir(), "media");
|
||||
const relative = path.relative(path.resolve(globalMediaRoot), path.resolve(candidate));
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
const firstSegment = relative.split(path.sep)[0] ?? "";
|
||||
return MANAGED_GLOBAL_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-");
|
||||
}
|
||||
|
||||
function isAllowedAbsoluteReplyMediaPath(params: {
|
||||
candidate: string;
|
||||
workspaceDir: string;
|
||||
sandboxRoot?: string;
|
||||
}): boolean {
|
||||
if (isManagedGlobalReplyMediaPath(params.candidate)) {
|
||||
return true;
|
||||
}
|
||||
const volatileRoots = [params.workspaceDir, params.sandboxRoot]
|
||||
.filter((root): root is string => Boolean(root))
|
||||
.map((root) => path.join(path.resolve(root), AGENT_STATE_MEDIA_DIRNAME));
|
||||
return volatileRoots.some((root) => isPathInside(root, params.candidate));
|
||||
}
|
||||
|
||||
function isLikelyLocalMediaSource(media: string): boolean {
|
||||
return (
|
||||
FILE_URL_RE.test(media) ||
|
||||
@@ -113,22 +139,53 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
sandboxRoot,
|
||||
});
|
||||
} catch (err) {
|
||||
if (workspaceOnly || !isLikelyLocalMediaSource(media)) {
|
||||
if (!isLikelyLocalMediaSource(media) || FILE_URL_RE.test(media)) {
|
||||
throw err;
|
||||
}
|
||||
if (FILE_URL_RE.test(media)) {
|
||||
if (workspaceOnly) {
|
||||
throw err;
|
||||
}
|
||||
if (!path.isAbsolute(media)) {
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
}
|
||||
if (
|
||||
isAllowedAbsoluteReplyMediaPath({
|
||||
candidate: media,
|
||||
workspaceDir: params.workspaceDir,
|
||||
sandboxRoot,
|
||||
})
|
||||
) {
|
||||
return media;
|
||||
}
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isLikelyLocalMediaSource(media)) {
|
||||
return media;
|
||||
}
|
||||
if (FILE_URL_RE.test(media)) {
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA file URLs are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
);
|
||||
}
|
||||
if (!path.isAbsolute(media)) {
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
}
|
||||
if (
|
||||
isAllowedAbsoluteReplyMediaPath({
|
||||
candidate: media,
|
||||
workspaceDir: params.workspaceDir,
|
||||
sandboxRoot,
|
||||
})
|
||||
) {
|
||||
return media;
|
||||
}
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
);
|
||||
};
|
||||
|
||||
return async (payload) => {
|
||||
@@ -140,7 +197,13 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
const normalizedMedia: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const media of mediaList) {
|
||||
const normalized = await persistVolatileAgentMedia(await normalizeMediaSource(media));
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = await persistVolatileAgentMedia(await normalizeMediaSource(media));
|
||||
} catch (err) {
|
||||
logVerbose(`dropping blocked reply media ${media}: ${String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -16,15 +16,24 @@ const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
|
||||
let fixtureRoot = "";
|
||||
let fakePdfFile = "";
|
||||
let oversizedJpegFile = "";
|
||||
let realPdfFile = "";
|
||||
let tinyPngFile = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
({ loadWebMedia } = await import("./web-media.js"));
|
||||
await mediaRootTracker.setup();
|
||||
fixtureRoot = await mediaRootTracker.make("case");
|
||||
fakePdfFile = path.join(fixtureRoot, "fake.pdf");
|
||||
realPdfFile = path.join(fixtureRoot, "real.pdf");
|
||||
tinyPngFile = path.join(fixtureRoot, "tiny.png");
|
||||
oversizedJpegFile = path.join(fixtureRoot, "oversized.jpg");
|
||||
await fs.writeFile(fakePdfFile, "TOP_SECRET_TEXT", "utf8");
|
||||
await fs.writeFile(
|
||||
realPdfFile,
|
||||
Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"),
|
||||
);
|
||||
await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
await fs.writeFile(
|
||||
oversizedJpegFile,
|
||||
@@ -175,4 +184,30 @@ describe("loadWebMedia", () => {
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host read capability", () => {
|
||||
it("rejects document uploads that only match by file extension", async () => {
|
||||
await expect(
|
||||
loadWebMedia(fakePdfFile, {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
hostReadCapability: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "path-not-allowed",
|
||||
});
|
||||
});
|
||||
|
||||
it("still allows real PDF uploads detected from file content", async () => {
|
||||
const result = await loadWebMedia(realPdfFile, {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
hostReadCapability: true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("document");
|
||||
expect(result.contentType).toBe("application/pdf");
|
||||
expect(result.fileName).toBe("real.pdf");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,15 +78,6 @@ const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
const HOST_READ_ALLOWED_DOCUMENT_EXTS = new Set([
|
||||
".doc",
|
||||
".docx",
|
||||
".pdf",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
]);
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function formatMb(bytes: number, digits = 2): string {
|
||||
@@ -121,23 +112,20 @@ function isHeicSource(opts: { contentType?: string; fileName?: string }): boolea
|
||||
function assertHostReadMediaAllowed(params: {
|
||||
contentType?: string;
|
||||
kind: MediaKind | undefined;
|
||||
mediaUrl: string;
|
||||
fileName?: string;
|
||||
}): void {
|
||||
if (params.kind !== "document") {
|
||||
if (params.kind === "image" || params.kind === "audio" || params.kind === "video") {
|
||||
return;
|
||||
}
|
||||
if (params.kind !== "document") {
|
||||
throw new LocalMediaAccessError(
|
||||
"path-not-allowed",
|
||||
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${params.contentType?.trim().toLowerCase() ?? "unknown"}).`,
|
||||
);
|
||||
}
|
||||
const normalizedMime = params.contentType?.trim().toLowerCase();
|
||||
if (normalizedMime && HOST_READ_ALLOWED_DOCUMENT_MIMES.has(normalizedMime)) {
|
||||
return;
|
||||
}
|
||||
const ext = path
|
||||
.extname(params.fileName ?? params.mediaUrl)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (ext && HOST_READ_ALLOWED_DOCUMENT_EXTS.has(ext)) {
|
||||
return;
|
||||
}
|
||||
throw new LocalMediaAccessError(
|
||||
"path-not-allowed",
|
||||
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${normalizedMime ?? "unknown"}).`,
|
||||
@@ -381,7 +369,9 @@ async function loadWebMediaInternal(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
|
||||
const detectedMime = await detectMime({ buffer: data, filePath: mediaUrl });
|
||||
const verifiedMime = hostReadCapability ? await detectMime({ buffer: data }) : detectedMime;
|
||||
const mime = verifiedMime ?? detectedMime;
|
||||
const kind = kindFromMime(mime);
|
||||
let fileName = path.basename(mediaUrl) || undefined;
|
||||
if (fileName && !path.extname(fileName) && mime) {
|
||||
@@ -392,10 +382,8 @@ async function loadWebMediaInternal(
|
||||
}
|
||||
if (hostReadCapability) {
|
||||
assertHostReadMediaAllowed({
|
||||
contentType: mime,
|
||||
kind,
|
||||
mediaUrl,
|
||||
fileName,
|
||||
contentType: verifiedMime,
|
||||
kind: kindFromMime(detectedMime ?? verifiedMime),
|
||||
});
|
||||
}
|
||||
return await clampAndFinalize({
|
||||
|
||||
Reference in New Issue
Block a user