From f190bf0a0703009a17e0c8033769ff28139e0776 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 14 Apr 2026 12:35:25 -0400 Subject: [PATCH] Fix Matrix media alias normalization --- extensions/matrix/src/actions.test.ts | 2 +- extensions/matrix/src/actions.ts | 4 +- .../outbound/message-action-params.test.ts | 37 ++++++++++++++++--- src/infra/outbound/message-action-params.ts | 37 ++++++++++++++----- src/param-key.ts | 17 +++++++-- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 70cff604395..2e4acf44b40 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -93,7 +93,7 @@ describe("matrixMessageActions", () => { expect(actions).toContain(profileAction); expect(supportsAction({ action: profileAction } as never)).toBe(true); expect(discovery.mediaSourceParams).toEqual({ - "set-profile": ["avatarUrl", "avatar_url", "avatarPath", "avatar_path"], + "set-profile": ["avatarUrl", "avatarPath"], }); expect(properties.displayName).toBeDefined(); expect(properties.avatarUrl).toBeDefined(); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 6797786b23b..e25202d3278 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -57,9 +57,7 @@ const MATRIX_PROFILE_MEDIA_PROPERTIES = { }), ), } as const; -const MATRIX_PROFILE_MEDIA_SOURCE_PARAMS = Object.freeze( - Object.keys(MATRIX_PROFILE_MEDIA_PROPERTIES), -); +const MATRIX_PROFILE_MEDIA_SOURCE_PARAMS = Object.freeze(["avatarUrl", "avatarPath"]); function createMatrixExposedActions(params: { gate: ReturnType; diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index f9a2c910b63..cbcc65b1898 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -13,12 +13,7 @@ import { const cfg = {} as OpenClawConfig; const maybeIt = process.platform === "win32" ? it.skip : it; -const matrixMediaSourceParamKeys = [ - "avatarPath", - "avatar_path", - "avatarUrl", - "avatar_url", -] as const; +const matrixMediaSourceParamKeys = ["avatarPath", "avatarUrl"] as const; describe("message action media helpers", () => { it("prefers sandbox media policy when sandbox roots are non-blank", () => { @@ -184,6 +179,36 @@ describe("message action media helpers", () => { } }); + maybeIt("prefers canonical Matrix media params over invalid snake_case aliases", async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-avatar-canonical-")); + try { + const args: Record = { + avatarUrl: "https://example.com/avatars/profile.png", + avatar_url: "data:text/plain;base64,QQ==", + avatarPath: "/workspace/avatars/profile.png", + avatar_path: "data:text/plain;base64,QQ==", + }; + + await normalizeSandboxMediaParams({ + args, + mediaPolicy: { + mode: "sandbox", + sandboxRoot, + }, + extraParamKeys: matrixMediaSourceParamKeys, + }); + + expect(args).toMatchObject({ + avatarUrl: "https://example.com/avatars/profile.png", + avatarPath: path.join(sandboxRoot, "avatars", "profile.png"), + avatar_url: "data:text/plain;base64,QQ==", + avatar_path: "data:text/plain;base64,QQ==", + }); + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }); + } + }); + maybeIt("keeps remote HTTP avatarUrl unchanged under sandbox normalization", async () => { const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-avatar-remote-")); try { diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index ed9559113fa..3b891b0f283 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -13,6 +13,7 @@ import { } from "../../media/load-options.js"; import { extensionForMime } from "../../media/mime.js"; import { loadWebMedia } from "../../media/web-media.js"; +import { resolveSnakeCaseParamKey } from "../../param-key.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -31,6 +32,24 @@ function readMediaParam(args: Record, key: string): string | un return readStringParam(args, key, { trim: false }); } +function resolveMediaParamEntry( + args: Record, + key: string, +): { key: string; value: string } | undefined { + const resolvedKey = resolveSnakeCaseParamKey(args, key); + if (!resolvedKey) { + return undefined; + } + const value = readMediaParam(args, key); + if (!value) { + return undefined; + } + return { + key: resolvedKey, + value, + }; +} + function buildActionMediaSourceParamKeys(extraParamKeys?: readonly string[]): string[] { const keys = new Set(BASE_ACTION_MEDIA_SOURCE_PARAM_KEYS); extraParamKeys?.forEach((key) => keys.add(key)); @@ -67,9 +86,9 @@ export function collectActionMediaSourceHints( ): string[] { const sources: string[] = []; for (const key of buildActionMediaSourceParamKeys(extraParamKeys)) { - const source = typeof args[key] === "string" ? args[key] : undefined; - if (source && normalizeOptionalString(source)) { - sources.push(source); + const entry = resolveMediaParamEntry(args, key); + if (entry && normalizeOptionalString(entry.value)) { + sources.push(entry.value); } } return sources; @@ -277,17 +296,17 @@ export async function normalizeSandboxMediaParams(params: { const sandboxRoot = params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; for (const key of buildActionMediaSourceParamKeys(params.extraParamKeys)) { - const raw = readMediaParam(params.args, key); - if (!raw) { + const entry = resolveMediaParamEntry(params.args, key); + if (!entry) { continue; } - assertMediaNotDataUrl(raw); + assertMediaNotDataUrl(entry.value); if (!sandboxRoot) { continue; } - const normalized = await resolveSandboxedMediaSource({ media: raw, sandboxRoot }); - if (normalized !== raw) { - params.args[key] = normalized; + const normalized = await resolveSandboxedMediaSource({ media: entry.value, sandboxRoot }); + if (normalized !== entry.value) { + params.args[entry.key] = normalized; } } } diff --git a/src/param-key.ts b/src/param-key.ts index ab8790caeb4..41b215695b0 100644 --- a/src/param-key.ts +++ b/src/param-key.ts @@ -7,13 +7,24 @@ function toSnakeCaseKey(key: string): string { return lowercasePreservingWhitespace(snakeKey); } -export function readSnakeCaseParamRaw(params: Record, key: string): unknown { +export function resolveSnakeCaseParamKey( + params: Record, + key: string, +): string | undefined { if (Object.hasOwn(params, key)) { - return params[key]; + return key; } const snakeKey = toSnakeCaseKey(key); if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { - return params[snakeKey]; + return snakeKey; + } + return undefined; +} + +export function readSnakeCaseParamRaw(params: Record, key: string): unknown { + const resolvedKey = resolveSnakeCaseParamKey(params, key); + if (resolvedKey) { + return params[resolvedKey]; } return undefined; }