fix(matrix): migrate room allow aliases to enabled (#60690)

* fix(matrix): migrate room allow aliases to enabled

* test(matrix): keep migration coverage on the channel seam

* chore(config): refresh baselines after matrix alias cleanup
This commit is contained in:
Vincent Koc
2026-04-04 14:27:50 +09:00
committed by GitHub
parent 6e0fe1b91e
commit b390591779
18 changed files with 1063 additions and 360 deletions

View File

@@ -37,7 +37,7 @@ describe("MatrixConfigSchema SecretInput", () => {
accessToken: "token",
groups: {
"!room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
},
@@ -55,7 +55,7 @@ describe("MatrixConfigSchema SecretInput", () => {
accessToken: "token",
rooms: {
"!room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
},

View File

@@ -45,7 +45,6 @@ const matrixRoomSchema = z
.object({
account: z.string().optional(),
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
tools: ToolPolicySchema,

View File

@@ -8,6 +8,7 @@ import {
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
matrixDoctor,
runMatrixDoctorSequence,
} from "./doctor.js";
@@ -125,4 +126,50 @@ describe("matrix doctor", () => {
});
expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot");
});
it("normalizes legacy Matrix room allow aliases to enabled", () => {
const normalize = matrixDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
matrix: {
groups: {
"!ops:example.org": {
allow: true,
},
},
accounts: {
work: {
rooms: {
"!legacy:example.org": {
allow: false,
},
},
},
},
},
},
} as never,
});
expect(result.config.channels?.matrix?.groups?.["!ops:example.org"]).toEqual({
enabled: true,
});
expect(result.config.channels?.matrix?.accounts?.work?.rooms?.["!legacy:example.org"]).toEqual(
{
enabled: false,
},
);
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (true).",
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (false).",
]),
);
});
});

View File

@@ -1,4 +1,8 @@
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
detectPluginInstallPathIssue,
@@ -19,6 +23,161 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasLegacyMatrixRoomAllowAlias(value: unknown): boolean {
const room = isRecord(value) ? value : null;
return Boolean(room && typeof room.allow === "boolean");
}
function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean {
const rooms = isRecord(value) ? value : null;
return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room)));
}
function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => {
if (!isRecord(account)) {
return false;
}
return (
hasLegacyMatrixRoomMapAllowAliases(account.groups) ||
hasLegacyMatrixRoomMapAllowAliases(account.rooms)
);
});
}
function normalizeMatrixRoomAllowAliases(params: {
rooms: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { rooms: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextRooms: Record<string, unknown> = { ...params.rooms };
for (const [roomId, roomValue] of Object.entries(params.rooms)) {
const room = isRecord(roomValue) ? roomValue : null;
if (!room || typeof room.allow !== "boolean") {
continue;
}
const nextRoom = { ...room };
if (typeof nextRoom.enabled !== "boolean") {
nextRoom.enabled = room.allow;
}
delete nextRoom.allow;
nextRooms[roomId] = nextRoom;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`,
);
}
return { rooms: nextRooms, changed };
}
function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const matrix = isRecord(channels?.matrix) ? channels.matrix : null;
if (!matrix) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedMatrix: Record<string, unknown> = matrix;
let changed = false;
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
if (!rooms) {
return;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.${key}`,
changes,
});
if (normalized.changed) {
updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms };
changed = true;
}
};
normalizeTopLevelRoomScope("groups");
normalizeTopLevelRoomScope("rooms");
const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
let nextAccount: Record<string, unknown> = account;
let accountChanged = false;
for (const key of ["groups", "rooms"] as const) {
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
if (!rooms) {
continue;
}
const normalized = normalizeMatrixRoomAllowAliases({
rooms,
pathPrefix: `channels.matrix.accounts.${accountId}.${key}`,
changes,
});
if (normalized.changed) {
nextAccount = { ...nextAccount, [key]: normalized.rooms };
accountChanged = true;
}
}
if (accountChanged) {
nextAccounts[accountId] = nextAccount;
accountsChanged = true;
}
}
if (accountsChanged) {
updatedMatrix = { ...updatedMatrix, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
matrix: updatedMatrix as OpenClawConfig["channels"]["matrix"],
},
},
changes,
};
}
const MATRIX_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "matrix", "groups"],
message:
"channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead (auto-migrated on load).",
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
"channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead (auto-migrated on load).",
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead (auto-migrated on load).",
match: hasLegacyMatrixAccountRoomAllowAliases,
},
];
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
return isRecord(channels?.matrix);
@@ -259,6 +418,8 @@ export const matrixDoctor: ChannelDoctorAdapter = {
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeMatrixCompatibilityConfig(cfg),
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
await runMatrixDoctorSequence({ cfg, env, shouldRepair }),
cleanStaleConfig: async ({ cfg }) => await cleanStaleMatrixPluginConfig(cfg),

View File

@@ -476,15 +476,15 @@ describe("resolveMatrixAccount", () => {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!axis-room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -503,20 +503,20 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.groups).toEqual({
"!axis-room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -529,15 +529,15 @@ describe("resolveMatrixAccount", () => {
accessToken: "default-token",
groups: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -552,20 +552,20 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -576,15 +576,15 @@ describe("resolveMatrixAccount", () => {
matrix: {
rooms: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!axis-room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -603,20 +603,20 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.rooms).toEqual({
"!axis-room:example.org": {
allow: true,
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -629,15 +629,15 @@ describe("resolveMatrixAccount", () => {
accessToken: "default-token",
rooms: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -652,20 +652,20 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -683,15 +683,15 @@ describe("resolveMatrixAccount", () => {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
},
@@ -700,11 +700,11 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "ops", env }).config.groups).toEqual({
"!ops-room:example.org": {
allow: true,
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -715,11 +715,11 @@ describe("resolveMatrixAccount", () => {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -734,7 +734,7 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -745,11 +745,11 @@ describe("resolveMatrixAccount", () => {
matrix: {
rooms: {
"!default-room:example.org": {
allow: true,
enabled: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -764,7 +764,7 @@ describe("resolveMatrixAccount", () => {
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!shared-room:example.org": {
allow: true,
enabled: true,
},
});
});
@@ -775,7 +775,7 @@ describe("resolveMatrixAccount", () => {
matrix: {
groups: {
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {
@@ -798,7 +798,7 @@ describe("resolveMatrixAccount", () => {
matrix: {
rooms: {
"!shared-room:example.org": {
allow: true,
enabled: true,
},
},
accounts: {

View File

@@ -122,7 +122,7 @@ describe("updateMatrixAccountConfig", () => {
policy: "pairing",
},
groups: {
"!default:example.org": { allow: true },
"!default:example.org": { enabled: true },
},
accounts: {
ops: {
@@ -145,14 +145,14 @@ describe("updateMatrixAccountConfig", () => {
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
"!ops-room:example.org": { enabled: true },
},
rooms: null,
});
expect(updated.channels?.["matrix"]?.dm?.policy).toBe("pairing");
expect(updated.channels?.["matrix"]?.groups).toEqual({
"!default:example.org": { allow: true },
"!default:example.org": { enabled: true },
});
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
dm: {
@@ -162,7 +162,7 @@ describe("updateMatrixAccountConfig", () => {
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
"!ops-room:example.org": { enabled: true },
},
});
expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined();

View File

@@ -39,13 +39,13 @@ describe("resolveMatrixMonitorConfig", () => {
);
const roomsConfig: MatrixRoomsConfig = {
"*": { allow: true },
"*": { enabled: true },
"room:!ops:example.org": {
allow: true,
enabled: true,
users: ["Dana", "user:@Erin:Example.org"],
},
General: {
allow: true,
enabled: true,
},
};
@@ -62,13 +62,13 @@ describe("resolveMatrixMonitorConfig", () => {
expect(result.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]);
expect(result.groupAllowFrom).toEqual(["@carol:example.org"]);
expect(result.roomsConfig).toEqual({
"*": { allow: true },
"*": { enabled: true },
"!ops:example.org": {
allow: true,
enabled: true,
users: ["@dana:example.org", "@erin:example.org"],
},
"!general:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveTargets).toHaveBeenCalledTimes(3);
@@ -116,7 +116,7 @@ describe("resolveMatrixMonitorConfig", () => {
groupAllowFrom: ["matrix:@known:example.org"],
roomsConfig: {
"channel:Project X": {
allow: true,
enabled: true,
users: ["matrix:Ghost"],
},
},
@@ -174,7 +174,7 @@ describe("resolveMatrixMonitorConfig", () => {
accountId: "ops",
roomsConfig: {
"#allowed:example.org": {
allow: true,
enabled: true,
},
},
runtime,
@@ -183,7 +183,7 @@ describe("resolveMatrixMonitorConfig", () => {
expect(result.roomsConfig).toEqual({
"!allowed-room:example.org": {
allow: true,
enabled: true,
},
});
expect(resolveTargets).toHaveBeenCalledWith(

View File

@@ -4,9 +4,9 @@ import { resolveMatrixRoomConfig } from "./rooms.js";
describe("resolveMatrixRoomConfig", () => {
it("matches room IDs and aliases, not names", () => {
const rooms = {
"!room:example.org": { allow: true },
"#alias:example.org": { allow: true },
"Project Room": { allow: true },
"!room:example.org": { enabled: true },
"#alias:example.org": { enabled: true },
"Project Room": { enabled: true },
};
const byId = resolveMatrixRoomConfig({
@@ -26,7 +26,7 @@ describe("resolveMatrixRoomConfig", () => {
expect(byAlias.matchKey).toBe("#alias:example.org");
const byName = resolveMatrixRoomConfig({
rooms: { "Project Room": { allow: true } },
rooms: { "Project Room": { enabled: true } },
roomId: "!different:example.org",
aliases: [],
});
@@ -37,7 +37,7 @@ describe("resolveMatrixRoomConfig", () => {
describe("matchSource classification", () => {
it('returns matchSource="direct" for exact room ID match', () => {
const result = resolveMatrixRoomConfig({
rooms: { "!room:example.org": { allow: true } },
rooms: { "!room:example.org": { enabled: true } },
roomId: "!room:example.org",
aliases: [],
});
@@ -47,7 +47,7 @@ describe("resolveMatrixRoomConfig", () => {
it('returns matchSource="direct" for alias match', () => {
const result = resolveMatrixRoomConfig({
rooms: { "#alias:example.org": { allow: true } },
rooms: { "#alias:example.org": { enabled: true } },
roomId: "!room:example.org",
aliases: ["#alias:example.org"],
});
@@ -57,7 +57,7 @@ describe("resolveMatrixRoomConfig", () => {
it('returns matchSource="wildcard" for wildcard match', () => {
const result = resolveMatrixRoomConfig({
rooms: { "*": { allow: true } },
rooms: { "*": { enabled: true } },
roomId: "!any:example.org",
aliases: [],
});
@@ -67,7 +67,7 @@ describe("resolveMatrixRoomConfig", () => {
it("returns undefined matchSource when no match", () => {
const result = resolveMatrixRoomConfig({
rooms: { "!other:example.org": { allow: true } },
rooms: { "!other:example.org": { enabled: true } },
roomId: "!room:example.org",
aliases: [],
});
@@ -78,8 +78,8 @@ describe("resolveMatrixRoomConfig", () => {
it("direct match takes priority over wildcard", () => {
const result = resolveMatrixRoomConfig({
rooms: {
"!room:example.org": { allow: true, systemPrompt: "room-specific" },
"*": { allow: true, systemPrompt: "generic" },
"!room:example.org": { enabled: true, systemPrompt: "room-specific" },
"*": { enabled: true, systemPrompt: "generic" },
},
roomId: "!room:example.org",
aliases: [],
@@ -96,7 +96,7 @@ describe("resolveMatrixRoomConfig", () => {
it("wildcard config should NOT be usable to override DM classification", () => {
const result = resolveMatrixRoomConfig({
rooms: { "*": { allow: true, skills: ["general"] } },
rooms: { "*": { enabled: true, skills: ["general"] } },
roomId: "!dm-room:example.org",
aliases: [],
});
@@ -108,8 +108,8 @@ describe("resolveMatrixRoomConfig", () => {
it("explicitly configured room should be usable to override DM classification", () => {
const result = resolveMatrixRoomConfig({
rooms: {
"!configured-room:example.org": { allow: true },
"*": { allow: true },
"!configured-room:example.org": { enabled: true },
"*": { enabled: true },
},
roomId: "!configured-room:example.org",
aliases: [],

View File

@@ -9,6 +9,11 @@ export type MatrixRoomConfigResolved = {
matchSource?: "direct" | "wildcard";
};
function readLegacyRoomAllowAlias(room: MatrixRoomConfig | undefined): boolean | undefined {
const rawRoom = room as Record<string, unknown> | undefined;
return typeof rawRoom?.allow === "boolean" ? rawRoom.allow : undefined;
}
export function resolveMatrixRoomConfig(params: {
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
@@ -33,7 +38,8 @@ export function resolveMatrixRoomConfig(params: {
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const legacyAllow = readLegacyRoomAllowAlias(resolved);
const allowed = resolved ? resolved.enabled !== false && legacyAllow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {

View File

@@ -308,7 +308,7 @@ describe("matrix onboarding", () => {
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
"!ops-room:example.org": { enabled: true },
},
});
expect(result.cfg.channels?.["matrix"]?.dm).toBeUndefined();

View File

@@ -183,7 +183,7 @@ function setMatrixGroupPolicy(
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
const groups = Object.fromEntries(roomKeys.map((key) => [key, { enabled: true }]));
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
groups,
rooms: null,

View File

@@ -23,10 +23,8 @@ export type MatrixDmConfig = {
export type MatrixRoomConfig = {
/** Restrict this room entry to a specific Matrix account in multi-account setups. */
account?: string;
/** If false, disable the bot in this room (alias for allow: false). */
/** If false, disable the bot in this room. */
enabled?: boolean;
/** Legacy room allow toggle; prefer enabled. */
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/**