diff --git a/CHANGELOG.md b/CHANGELOG.md index f77ca9f3154..c48dd789232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -209,6 +209,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: scope assistant-side fallback classification and surfaced provider errors to the current attempt instead of stale session history, so cross-provider fallback runs stop inheriting the previous provider's failure. (#62907) Thanks @stainlu. - MiniMax/OAuth: write `api: "anthropic-messages"` and `authHeader: true` into the `minimax-portal` config patch during `openclaw configure`, so re-authenticated portal setups keep Bearer auth routing working. (#64964) Thanks @ryanlee666. - Agents/tools: stop repeated unavailable-tool retries from escaping loop detection when the model changes arguments, and rewrite over-threshold unknown tool calls into plain assistant text before dispatch. (#65922) Thanks @dutifulbob. +- Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear. ## 2026.4.10 diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 61d3c70ecb3..49609eda75b 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -130,6 +130,16 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("preserves remote mxc:// media sources", async () => { + await withSandboxRoot(async (sandboxDir) => { + const result = await resolveSandboxedMediaSource({ + media: "mxc://matrix.org/abc123def456", + sandboxRoot: sandboxDir, + }); + expect(result).toBe("mxc://matrix.org/abc123def456"); + }); + }); + // Group 3: Rejections (security) it.each([ { diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 7303f225881..77e16c16be6 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -10,11 +10,10 @@ import { import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js"; import { isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { isPassThroughRemoteMediaSource } from "../media/media-source-url.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; -const HTTP_URL_RE = /^https?:\/\//i; const DATA_URL_RE = /^data:/i; -const MXC_URL_RE = /^mxc:\/\//i; const SANDBOX_CONTAINER_WORKDIR = "/workspace"; function normalizeUnicodeSpaces(str: string): string { @@ -109,10 +108,7 @@ export async function resolveSandboxedMediaSource(params: { if (!raw) { return raw; } - if (HTTP_URL_RE.test(raw)) { - return raw; - } - if (MXC_URL_RE.test(raw)) { + if (isPassThroughRemoteMediaSource(raw)) { return raw; } let candidate = raw; diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 8f3857a2b92..f070e63bbff 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { + collectActionMediaSourceHints, hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, @@ -129,6 +130,24 @@ describe("message action media helpers", () => { } }); + it("collects host media source hints from the shared media-source key set", () => { + expect( + collectActionMediaSourceHints({ + media: " /workspace/uploads/photo.png ", + filePath: "", + image: "file:///workspace/assets/event-cover.png", + avatarPath: "/workspace/avatars/profile.png", + avatar_url: "mxc://matrix.org/abc123def456", + ignored: "/workspace/not-included.png", + }), + ).toEqual([ + " /workspace/uploads/photo.png ", + "file:///workspace/assets/event-cover.png", + "/workspace/avatars/profile.png", + "mxc://matrix.org/abc123def456", + ]); + }); + maybeIt("normalizes Matrix snake_case avatar_path and avatar_url aliases", async () => { const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-avatar-snake-")); try { diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 2eb7c972981..08022a01cdc 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -13,10 +13,11 @@ import { import { extensionForMime } from "../../media/mime.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; export const readBooleanParam = readBooleanParamShared; -const SANDBOX_MEDIA_PARAM_KEYS = [ +export const ACTION_MEDIA_SOURCE_PARAM_KEYS = [ "media", "path", "filePath", @@ -31,11 +32,22 @@ const SANDBOX_MEDIA_PARAM_KEYS = [ function readMediaParam( args: Record, - key: (typeof SANDBOX_MEDIA_PARAM_KEYS)[number], + key: (typeof ACTION_MEDIA_SOURCE_PARAM_KEYS)[number], ): string | undefined { return readStringParam(args, key, { trim: false }); } +export function collectActionMediaSourceHints(args: Record): string[] { + const sources: string[] = []; + for (const key of ACTION_MEDIA_SOURCE_PARAM_KEYS) { + const source = typeof args[key] === "string" ? args[key] : undefined; + if (normalizeOptionalString(source)) { + sources.push(source); + } + } + return sources; +} + function readAttachmentMediaHint(args: Record): string | undefined { return readMediaParam(args, "media") ?? readMediaParam(args, "mediaUrl"); } @@ -236,7 +248,7 @@ export async function normalizeSandboxMediaParams(params: { }): Promise { const sandboxRoot = params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; - for (const key of SANDBOX_MEDIA_PARAM_KEYS) { + for (const key of ACTION_MEDIA_SOURCE_PARAM_KEYS) { const raw = readMediaParam(params.args, key); if (!raw) { continue; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 4a5acea3952..b51d4de9f75 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -42,6 +42,7 @@ import { import type { OutboundSendDeps } from "./deliver.js"; import { normalizeMessageActionInput } from "./message-action-normalization.js"; import { + collectActionMediaSourceHints, hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, @@ -203,19 +204,6 @@ async function resolveGatewayActionIdempotencyKey(idempotencyKey?: string): Prom const { randomIdempotencyKey } = await loadMessageActionGatewayRuntime(); return randomIdempotencyKey(); } - -function collectActionMediaSourceHints(params: Record): string[] { - const sources: string[] = []; - for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl", "image", "avatarPath", "avatar_path", "avatarUrl", "avatar_url"] as const) { - const source = typeof params[key] === "string" ? params[key] : undefined; - const normalized = normalizeOptionalString(source); - if (normalized && source) { - sources.push(source); - } - } - return sources; -} - function applyCrossContextMessageDecoration({ params, message, diff --git a/src/media/media-source-url.ts b/src/media/media-source-url.ts new file mode 100644 index 00000000000..3b2e3bbbf11 --- /dev/null +++ b/src/media/media-source-url.ts @@ -0,0 +1,7 @@ +const HTTP_URL_RE = /^https?:\/\//i; +const MXC_URL_RE = /^mxc:\/\//i; + +export function isPassThroughRemoteMediaSource(value: string | null | undefined): boolean { + const normalized = value?.trim() ?? ""; + return Boolean(normalized) && (HTTP_URL_RE.test(normalized) || MXC_URL_RE.test(normalized)); +}