From cac40c01e97045cd2e434e8697c4afd3dab8d050 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 5 Apr 2026 14:57:44 -0400 Subject: [PATCH] fix(matrix): move avatar setup into account config (#61437) Merged via squash. Prepared head SHA: 4dd887a4746553eebc26b4fb93186fed9caf96f5 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + extensions/matrix/src/cli.test.ts | 43 +++++++++ extensions/matrix/src/cli.ts | 2 +- extensions/matrix/src/onboarding.test.ts | 3 + extensions/matrix/src/onboarding.ts | 7 +- extensions/matrix/src/setup-config.ts | 88 ++++++------------ extensions/matrix/src/setup-contract.ts | 24 ++--- extensions/matrix/src/setup-core.test.ts | 112 +++++++++++++++++++++++ src/channels/plugins/types.core.ts | 1 + 9 files changed, 201 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88bf490ec03..8ca261347d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,7 @@ Docs: https://docs.openclaw.ai - Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras. - Agents/video generation: accept `agents.defaults.videoGenerationModel` in strict config validation and `openclaw config set/get`, so gateways using `video_generate` no longer fail to boot after enabling a video model. - Discord/image generation: persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop failing with missing local workspace paths. +- Matrix: move legacy top-level `avatarUrl` into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras. ## 2026.4.2 diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 8aee1e22a6b..ac9b5a3db2c 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -607,6 +607,49 @@ describe("matrix CLI verification commands", () => { ); }); + it("forwards --avatar-url through account add setup and profile sync", async () => { + matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} }); + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix", + "account", + "add", + "--name", + "Ops Bot", + "--homeserver", + "https://matrix.example.org", + "--access-token", + "ops-token", + "--avatar-url", + "mxc://example/ops-avatar", + ], + { from: "user" }, + ); + + expect(matrixSetupApplyAccountConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops-bot", + input: expect.objectContaining({ + name: "Ops Bot", + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + avatarUrl: "mxc://example/ops-avatar", + }), + }), + ); + expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops-bot", + displayName: "Ops Bot", + avatarUrl: "mxc://example/ops-avatar", + }), + ); + expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops-bot"); + expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.ops-bot"); + }); + it("sets profile name and avatar via profile set command", async () => { const program = buildProgram(); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 92a30e209f1..d791d40d9ce 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -177,7 +177,7 @@ async function addMatrixAccount(params: { throw new Error("Matrix account setup is unavailable."); } - const input: ChannelSetupInput & { avatarUrl?: string } = { + const input: ChannelSetupInput = { name: params.name, avatarUrl: params.avatarUrl, homeserver: params.homeserver, diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 95f92b021e2..42f19086158 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -102,6 +102,7 @@ describe("matrix onboarding", () => { homeserver: "https://matrix.main.example.org", userId: "@main:example.org", accessToken: "main-token", + avatarUrl: "mxc://matrix.main.example.org/main-avatar", }, }, } as CoreConfig, @@ -117,10 +118,12 @@ describe("matrix onboarding", () => { expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined(); expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined(); + expect(result.cfg.channels?.matrix?.avatarUrl).toBeUndefined(); expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({ homeserver: "https://matrix.main.example.org", userId: "@main:example.org", accessToken: "main-token", + avatarUrl: "mxc://matrix.main.example.org/main-avatar", }); expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({ name: "ops", diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index e11643ca903..8538478bf8c 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -31,13 +31,13 @@ import { hasConfiguredSecretInput, isPrivateOrLoopbackHost, mergeAllowFromEntries, - moveSingleAccountChannelSectionToDefaultAccount, normalizeAccountId, promptChannelAccessConfig, promptAccountId, type RuntimeEnv, type WizardPrompter, } from "./runtime-api.js"; +import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; @@ -250,10 +250,7 @@ async function runMatrixConfigure(params: { await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); } if (accountId !== DEFAULT_ACCOUNT_ID) { - next = moveSingleAccountChannelSectionToDefaultAccount({ - cfg: next, - channelKey: channel, - }) as CoreConfig; + next = moveSingleMatrixAccountConfigToNamedAccount(next); } next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); } else { diff --git a/extensions/matrix/src/setup-config.ts b/extensions/matrix/src/setup-config.ts index 7cdaedef27c..e2366ba0757 100644 --- a/extensions/matrix/src/setup-config.ts +++ b/extensions/matrix/src/setup-config.ts @@ -7,6 +7,12 @@ import { } from "openclaw/plugin-sdk/setup"; import { resolveMatrixEnvAuthReadiness } from "./matrix/client/env-auth.js"; import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { isSupportedMatrixAvatarSource } from "./matrix/profile.js"; +import { + matrixNamedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, + matrixSingleAccountKeysToMove, +} from "./setup-contract.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; @@ -32,48 +38,8 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ "groupAllowFrom", "defaultTo", ]); -const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ - "deviceId", - "avatarUrl", - "initialSyncLimit", - "encryption", - "allowlistOnly", - "allowBots", - "blockStreaming", - "replyToMode", - "threadReplies", - "textChunkLimit", - "chunkMode", - "responsePrefix", - "ackReaction", - "ackReactionScope", - "reactionNotifications", - "threadBindings", - "startupVerification", - "startupVerificationCooldownHours", - "mediaMaxMb", - "autoJoin", - "autoJoinAllowlist", - "dm", - "groups", - "rooms", - "actions", -]); -const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([ - // When named accounts already exist, only move auth/bootstrap fields into the - // promoted account. Delivery-policy fields stay at the top level so they - // remain shared inherited defaults for every account. - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceId", - "deviceName", - "avatarUrl", - "initialSyncLimit", - "encryption", -]); +const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set(matrixSingleAccountKeysToMove); +const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set(matrixNamedAccountPromotionKeys); function cloneIfObject(value: T): T { if (value && typeof value === "object") { @@ -82,7 +48,16 @@ function cloneIfObject(value: T): T { return value; } -function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig { +function resolveSetupAvatarUrl(input: ChannelSetupInput): string | undefined { + const avatarUrl = input.avatarUrl; + if (typeof avatarUrl !== "string") { + return undefined; + } + const trimmed = avatarUrl.trim(); + return trimmed || undefined; +} + +export function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig { const channels = cfg.channels as Record | undefined; const baseConfig = channels?.[channel]; const base = @@ -119,23 +94,7 @@ function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfi return cfg; } - const defaultAccount = - typeof base.defaultAccount === "string" && base.defaultAccount.trim() - ? normalizeAccountId(base.defaultAccount) - : undefined; - const targetAccountId = - defaultAccount && defaultAccount !== DEFAULT_ACCOUNT_ID - ? (Object.entries(accounts).find( - ([accountId, value]) => - accountId && - value && - typeof value === "object" && - normalizeAccountId(accountId) === defaultAccount, - )?.[0] ?? DEFAULT_ACCOUNT_ID) - : (defaultAccount ?? - (Object.keys(accounts).filter(Boolean).length === 1 - ? Object.keys(accounts).filter(Boolean)[0] - : DEFAULT_ACCOUNT_ID)); + const targetAccountId = resolveSingleAccountPromotionTarget({ channel: base }); const nextAccount: Record = { ...(accounts[targetAccountId] ?? {}) }; for (const key of keysToMove) { @@ -165,6 +124,10 @@ export function validateMatrixSetupInput(params: { accountId: string; input: ChannelSetupInput; }): string | null { + const avatarUrl = resolveSetupAvatarUrl(params.input); + if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) { + return "Matrix avatar URL must be an mxc:// URI or an http(s) URL."; + } if (params.input.useEnv) { const envReadiness = resolveMatrixEnvAuthReadiness(params.accountId, process.env); return envReadiness.ready ? null : envReadiness.missingMessage; @@ -193,7 +156,6 @@ export function applyMatrixSetupAccountConfig(params: { cfg: CoreConfig; accountId: string; input: ChannelSetupInput; - avatarUrl?: string; }): CoreConfig { const normalizedAccountId = normalizeAccountId(params.accountId); const migratedCfg = @@ -206,6 +168,7 @@ export function applyMatrixSetupAccountConfig(params: { accountId: normalizedAccountId, name: params.input.name, }) as CoreConfig; + const avatarUrl = resolveSetupAvatarUrl(params.input); if (params.input.useEnv) { return updateMatrixAccountConfig(next, normalizedAccountId, { @@ -218,6 +181,7 @@ export function applyMatrixSetupAccountConfig(params: { password: null, deviceId: null, deviceName: null, + avatarUrl, }); } @@ -238,7 +202,7 @@ export function applyMatrixSetupAccountConfig(params: { accessToken: accessToken || (password ? null : undefined), password: password || (accessToken ? null : undefined), deviceName: params.input.deviceName?.trim(), - avatarUrl: params.avatarUrl, + avatarUrl, initialSyncLimit: params.input.initialSyncLimit, }); } diff --git a/extensions/matrix/src/setup-contract.ts b/extensions/matrix/src/setup-contract.ts index dc9a5d8edd6..7dd921ba5aa 100644 --- a/extensions/matrix/src/setup-contract.ts +++ b/extensions/matrix/src/setup-contract.ts @@ -1,6 +1,6 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; -const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [ +export const matrixSingleAccountKeysToMove = [ "deviceId", "avatarUrl", "initialSyncLimit", @@ -28,7 +28,7 @@ const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [ "actions", ] as const; -const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [ +export const matrixNamedAccountPromotionKeys = [ // When named accounts already exist, only move auth/bootstrap fields into the // promoted account. Shared delivery-policy fields stay at the top level. "name", @@ -43,8 +43,8 @@ const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [ "encryption", ] as const; -export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE]; -export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS]; +export const singleAccountKeysToMove = [...matrixSingleAccountKeysToMove]; +export const namedAccountPromotionKeys = [...matrixNamedAccountPromotionKeys]; export function resolveSingleAccountPromotionTarget(params: { channel: Record; @@ -57,19 +57,19 @@ export function resolveSingleAccountPromotionTarget(params: { typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim() ? normalizeAccountId(params.channel.defaultAccount) : undefined; - if (normalizedDefaultAccount) { - if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) { - const matchedAccountId = Object.entries(accounts).find( + const matchedAccountId = normalizedDefaultAccount + ? Object.entries(accounts).find( ([accountId, value]) => accountId && value && typeof value === "object" && normalizeAccountId(accountId) === normalizedDefaultAccount, - )?.[0]; - if (matchedAccountId) { - return matchedAccountId; - } - } + )?.[0] + : undefined; + if (matchedAccountId) { + return matchedAccountId; + } + if (normalizedDefaultAccount) { return DEFAULT_ACCOUNT_ID; } const namedAccounts = Object.entries(accounts).filter( diff --git a/extensions/matrix/src/setup-core.test.ts b/extensions/matrix/src/setup-core.test.ts index 1c56eaaeb26..1860349de0a 100644 --- a/extensions/matrix/src/setup-core.test.ts +++ b/extensions/matrix/src/setup-core.test.ts @@ -44,6 +44,52 @@ describe("matrixSetupAdapter", () => { }); }); + it("reuses an existing raw default-account key during promotion", () => { + const cfg = { + channels: { + matrix: { + defaultAccount: "default", + homeserver: "https://matrix.example.org", + userId: "@default:example.org", + accessToken: "default-token", + avatarUrl: "mxc://example.org/default-avatar", + accounts: { + Default: { + enabled: true, + deviceName: "Legacy raw key", + }, + }, + }, + }, + } as CoreConfig; + + const next = matrixSetupAdapter.applyAccountConfig({ + cfg, + accountId: "ops", + input: { + name: "Ops", + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }) as CoreConfig; + + expect(next.channels?.matrix?.accounts?.Default).toMatchObject({ + enabled: true, + deviceName: "Legacy raw key", + homeserver: "https://matrix.example.org", + userId: "@default:example.org", + accessToken: "default-token", + avatarUrl: "mxc://example.org/default-avatar", + }); + expect(next.channels?.matrix?.accounts?.default).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops).toMatchObject({ + name: "Ops", + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }); + }); + it("clears stored auth fields when switching an account to env-backed auth", () => { const cfg = { channels: { @@ -86,6 +132,40 @@ describe("matrixSetupAdapter", () => { expect(next.channels?.matrix?.accounts?.ops?.deviceName).toBeUndefined(); }); + it("keeps avatarUrl when switching an account to env-backed auth", () => { + const cfg = { + channels: { + matrix: { + accounts: { + ops: { + name: "Ops", + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig; + + const next = matrixSetupAdapter.applyAccountConfig({ + cfg, + accountId: "ops", + input: { + name: "Ops", + useEnv: true, + avatarUrl: " mxc://example.org/ops-avatar ", + }, + }) as CoreConfig; + + expect(next.channels?.matrix?.accounts?.ops).toMatchObject({ + name: "Ops", + enabled: true, + avatarUrl: "mxc://example.org/ops-avatar", + }); + expect(next.channels?.matrix?.accounts?.ops?.homeserver).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.accessToken).toBeUndefined(); + }); + it("stores proxy in account setup updates", () => { const next = matrixSetupAdapter.applyAccountConfig({ cfg: {} as CoreConfig, @@ -105,6 +185,38 @@ describe("matrixSetupAdapter", () => { }); }); + it("stores avatarUrl from setup input on the target account", () => { + const next = matrixSetupAdapter.applyAccountConfig({ + cfg: {} as CoreConfig, + accountId: "ops", + input: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + avatarUrl: " mxc://example.org/ops-avatar ", + }, + }) as CoreConfig; + + expect(next.channels?.matrix?.accounts?.ops).toMatchObject({ + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + avatarUrl: "mxc://example.org/ops-avatar", + }); + }); + + it("rejects unsupported avatar URL schemes during setup validation", () => { + const validationError = matrixSetupAdapter.validateInput?.({ + accountId: "ops", + input: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + avatarUrl: "file:///tmp/avatar.png", + }, + }); + + expect(validationError).toBe("Matrix avatar URL must be an mxc:// URI or an http(s) URL."); + }); + it("stores canonical dangerous private-network opt-in from setup input", () => { const next = matrixSetupAdapter.applyAccountConfig({ cfg: {} as CoreConfig, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 7335eb36d01..f111ee0e873 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -92,6 +92,7 @@ export type ChannelSetupInput = { accessToken?: string; password?: string; deviceName?: string; + avatarUrl?: string; initialSyncLimit?: number; ship?: string; url?: string;