Doctor: fix matrix default-account migration

This commit is contained in:
Gustavo Madeira Santana
2026-03-14 13:24:22 +00:00
parent 997e87dd4a
commit d51920c6ac
3 changed files with 260 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import type { ChannelSetupInput } from "./types.core.js";
type ChannelSectionBase = {
name?: string;
defaultAccount?: string;
accounts?: Record<string, Record<string, unknown>>;
};
@@ -363,6 +364,45 @@ const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record<string, ReadonlySet<string>
telegram: new Set(["streaming"]),
};
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
"encryption",
]);
export const MATRIX_SHARED_MULTI_ACCOUNT_DEFAULT_KEYS = new Set([
"dmPolicy",
"allowFrom",
"groupPolicy",
"groupAllowFrom",
"allowlistOnly",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"ackReaction",
"ackReactionScope",
"reactionNotifications",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
]);
export function shouldMoveSingleAccountChannelKey(params: {
channelKey: string;
key: string;
@@ -373,6 +413,54 @@ export function shouldMoveSingleAccountChannelKey(params: {
return SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL[params.channelKey]?.has(params.key) ?? false;
}
export function resolveSingleAccountKeysToMove(params: {
channelKey: string;
channel: Record<string, unknown>;
}): string[] {
const hasNamedAccounts =
Object.keys((params.channel.accounts as Record<string, unknown>) ?? {}).filter(Boolean).length >
0;
return Object.entries(params.channel)
.filter(([key, value]) => {
if (key === "accounts" || key === "enabled" || value === undefined) {
return false;
}
if (!shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key })) {
return false;
}
if (
params.channelKey === "matrix" &&
hasNamedAccounts &&
!MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS.has(key)
) {
return false;
}
return true;
})
.map(([key]) => key);
}
export function resolveSingleAccountPromotionTarget(params: {
channelKey: string;
channel: ChannelSectionBase;
}): string {
if (params.channelKey !== "matrix") {
return DEFAULT_ACCOUNT_ID;
}
const normalizedDefaultAccount = normalizeAccountId(params.channel.defaultAccount);
if (normalizedDefaultAccount === DEFAULT_ACCOUNT_ID) {
return DEFAULT_ACCOUNT_ID;
}
const accounts = params.channel.accounts ?? {};
if (
accounts[normalizedDefaultAccount] &&
typeof accounts[normalizedDefaultAccount] === "object"
) {
return normalizedDefaultAccount;
}
return DEFAULT_ACCOUNT_ID;
}
function cloneIfObject<T>(value: T): T {
if (value && typeof value === "object") {
return structuredClone(value);
@@ -397,18 +485,50 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: {
const accounts = base.accounts ?? {};
if (Object.keys(accounts).length > 0) {
return params.cfg;
}
if (params.channelKey !== "matrix") {
return params.cfg;
}
const keysToMove = resolveSingleAccountKeysToMove({
channelKey: params.channelKey,
channel: base,
});
if (keysToMove.length === 0) {
return params.cfg;
}
const keysToMove = Object.entries(base)
.filter(
([key, value]) =>
key !== "accounts" &&
key !== "enabled" &&
value !== undefined &&
shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key }),
)
.map(([key]) => key);
const targetAccountId = resolveSingleAccountPromotionTarget({
channelKey: params.channelKey,
channel: base,
});
const defaultAccount: Record<string, unknown> = {
...accounts[targetAccountId],
};
for (const key of keysToMove) {
const value = base[key];
defaultAccount[key] = cloneIfObject(value);
}
const nextChannel: ChannelSectionRecord = { ...base };
for (const key of keysToMove) {
delete nextChannel[key];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...nextChannel,
accounts: {
...accounts,
[targetAccountId]: defaultAccount,
},
},
},
} as OpenClawConfig;
}
const keysToMove = resolveSingleAccountKeysToMove({
channelKey: params.channelKey,
channel: base,
});
const defaultAccount: Record<string, unknown> = {};
for (const key of keysToMove) {
const value = base[key];

View File

@@ -263,6 +263,64 @@ describe("normalizeCompatibilityConfigValues", () => {
);
});
it("does not create matrix.accounts.default for shared top-level defaults", () => {
const res = normalizeCompatibilityConfigValues({
channels: {
matrix: {
defaultAccount: "main",
dm: { policy: "pairing" },
accounts: {
main: {
homeserver: "https://matrix.example.org",
userId: "@main:example.org",
password: "main-password",
},
poe: {
homeserver: "https://matrix.example.org",
userId: "@poe:example.org",
password: "poe-password",
},
},
},
},
});
expect(res.config.channels?.matrix?.dm).toEqual({ policy: "pairing" });
expect(res.config.channels?.matrix?.accounts?.default).toBeUndefined();
expect(res.changes).toEqual([]);
});
it("repairs synthetic matrix.accounts.default shared defaults back to top-level", () => {
const res = normalizeCompatibilityConfigValues({
channels: {
matrix: {
defaultAccount: "main",
accounts: {
main: {
homeserver: "https://matrix.example.org",
userId: "@main:example.org",
password: "main-password",
},
poe: {
homeserver: "https://matrix.example.org",
userId: "@poe:example.org",
password: "poe-password",
},
default: {
dm: { policy: "pairing" },
},
},
},
},
});
expect(res.config.channels?.matrix?.dm).toEqual({ policy: "pairing" });
expect(res.config.channels?.matrix?.accounts?.default).toBeUndefined();
expect(res.changes).toContain(
"Moved shared Matrix defaults from channels.matrix.accounts.default back to channels.matrix.*.",
);
});
it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => {
const res = normalizeCompatibilityConfigValues({
browser: {

View File

@@ -1,4 +1,8 @@
import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js";
import {
MATRIX_SHARED_MULTI_ACCOUNT_DEFAULT_KEYS,
resolveSingleAccountKeysToMove,
resolveSingleAccountPromotionTarget,
} from "../channels/plugins/setup-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import {
formatSlackStreamingBooleanMigrationMessage,
@@ -377,20 +381,23 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
continue;
}
const keysToMove = Object.entries(rawChannel)
.filter(
([key, value]) =>
key !== "accounts" &&
key !== "enabled" &&
value !== undefined &&
shouldMoveSingleAccountChannelKey({ channelKey: channelId, key }),
)
.map(([key]) => key);
const keysToMove = resolveSingleAccountKeysToMove({
channelKey: channelId,
channel: rawChannel,
});
if (keysToMove.length === 0) {
continue;
}
const defaultAccount: Record<string, unknown> = {};
const targetAccountId = resolveSingleAccountPromotionTarget({
channelKey: channelId,
channel: rawChannel,
});
const defaultAccount: Record<string, unknown> = {
...(typeof rawAccounts[targetAccountId] === "object" && rawAccounts[targetAccountId]
? structuredClone(rawAccounts[targetAccountId] as Record<string, unknown>)
: {}),
};
for (const key of keysToMove) {
const value = rawChannel[key];
defaultAccount[key] = value && typeof value === "object" ? structuredClone(value) : value;
@@ -403,13 +410,13 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
nextChannel.accounts = {
...rawAccounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
[targetAccountId]: defaultAccount,
};
nextChannels[channelId] = nextChannel;
channelsChanged = true;
changes.push(
`Moved channels.${channelId} single-account top-level values into channels.${channelId}.accounts.default.`,
`Moved channels.${channelId} single-account top-level values into channels.${channelId}.accounts.${targetAccountId}.`,
);
}
@@ -428,6 +435,57 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
seedMissingDefaultAccountsFromSingleAccountBase();
normalizeLegacyBrowserProfiles();
const repairSyntheticMatrixDefaultAccount = () => {
const rawMatrix = next.channels?.matrix;
if (!isRecord(rawMatrix)) {
return;
}
const rawAccounts = rawMatrix.accounts;
if (!isRecord(rawAccounts)) {
return;
}
const syntheticDefault = rawAccounts[DEFAULT_ACCOUNT_ID];
if (!isRecord(syntheticDefault)) {
return;
}
const namedDefaultAccount =
typeof rawMatrix.defaultAccount === "string" ? rawMatrix.defaultAccount.trim() : "";
if (!namedDefaultAccount || namedDefaultAccount.toLowerCase() === DEFAULT_ACCOUNT_ID) {
return;
}
const defaultKeys = Object.keys(syntheticDefault);
if (
defaultKeys.length === 0 ||
defaultKeys.some((key) => !MATRIX_SHARED_MULTI_ACCOUNT_DEFAULT_KEYS.has(key))
) {
return;
}
const nextMatrix: Record<string, unknown> = { ...rawMatrix };
for (const key of defaultKeys) {
if (nextMatrix[key] === undefined) {
const value = syntheticDefault[key];
nextMatrix[key] = value && typeof value === "object" ? structuredClone(value) : value;
}
}
const nextAccounts = { ...rawAccounts };
delete nextAccounts[DEFAULT_ACCOUNT_ID];
nextMatrix.accounts = nextAccounts;
next = {
...next,
channels: {
...next.channels,
matrix: nextMatrix as OpenClawConfig["channels"]["matrix"],
},
};
changes.push(
"Moved shared Matrix defaults from channels.matrix.accounts.default back to channels.matrix.*.",
);
};
repairSyntheticMatrixDefaultAccount();
const normalizeBrowserSsrFPolicyAlias = () => {
const rawBrowser = next.browser;
if (!isRecord(rawBrowser)) {