Fix Matrix media alias normalization

This commit is contained in:
Gustavo Madeira Santana
2026-04-14 12:35:25 -04:00
parent 7b05b4b68e
commit f190bf0a07
5 changed files with 75 additions and 22 deletions

View File

@@ -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();

View File

@@ -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<typeof createActionGate>;

View File

@@ -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<string, unknown> = {
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 {

View File

@@ -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<string, unknown>, key: string): string | un
return readStringParam(args, key, { trim: false });
}
function resolveMediaParamEntry(
args: Record<string, unknown>,
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<string>(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;
}
}
}

View File

@@ -7,13 +7,24 @@ function toSnakeCaseKey(key: string): string {
return lowercasePreservingWhitespace(snakeKey);
}
export function readSnakeCaseParamRaw(params: Record<string, unknown>, key: string): unknown {
export function resolveSnakeCaseParamKey(
params: Record<string, unknown>,
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<string, unknown>, key: string): unknown {
const resolvedKey = resolveSnakeCaseParamKey(params, key);
if (resolvedKey) {
return params[resolvedKey];
}
return undefined;
}