fix(matrix): hot-reload dm.allowFrom and groupAllowFrom on each inbound message (#68546)

Merged via squash.

Prepared head SHA: ab369851c8
Co-authored-by: johnlanni <6763318+johnlanni@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
澄潭
2026-04-20 03:55:18 +08:00
committed by GitHub
parent 55f094ea33
commit f38a498985
7 changed files with 428 additions and 13 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Cron/gateway: reject ambiguous announce delivery config at add/update time so invalid multi-channel or target-id provider settings fail early instead of persisting broken cron jobs. (#69015) Thanks @obviyus.
- Cron/main-session delivery: preserve `heartbeat.target="last"` through deferred wake queuing, gateway wake forwarding, and same-target wake coalescing so queued cron replies still return to the last active chat. (#69021) Thanks @obviyus.
- Cron/gateway: ignore disabled channels when announce delivery ambiguity is checked, and validate main-session delivery patches against the live cron service default agent so hot-reloaded agent config does not falsely reject valid updates. (#69040) Thanks @obviyus.
- Matrix/allowlists: hot-reload `dm.allowFrom` and `groupAllowFrom` entries on inbound messages while keeping config removals authoritative, so Matrix allowlist changes no longer require a channel restart to add or revoke a sender. (#68546) Thanks @johnlanni.
## 2026.4.19-beta.2

View File

@@ -148,3 +148,28 @@ export function resolveMatrixAccountConfig(params: {
...(rooms ? { rooms } : {}),
};
}
export function resolveMatrixAccountAllowlistConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): {
dmAllowFrom?: NonNullable<MatrixConfig["dm"]>["allowFrom"];
groupAllowFrom?: MatrixConfig["groupAllowFrom"];
} {
const accountId = normalizeAccountId(params.accountId);
const base = resolveMatrixBaseConfig(params.cfg);
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const accountDm = accountConfig?.dm;
let dmAllowFrom = base.dm?.allowFrom;
if (accountDm && Object.hasOwn(accountDm, "allowFrom")) {
dmAllowFrom = accountDm.allowFrom;
}
let groupAllowFrom = base.groupAllowFrom;
if (accountConfig && Object.hasOwn(accountConfig, "groupAllowFrom")) {
groupAllowFrom = accountConfig.groupAllowFrom;
}
return { dmAllowFrom, groupAllowFrom };
}

View File

@@ -14,6 +14,16 @@ import {
type MatrixRoomsConfig = Record<string, MatrixRoomConfig>;
type ResolveMatrixTargetsFn = typeof resolveMatrixTargets;
export type MatrixResolvedAllowlistEntry = {
input: string;
id: string;
};
type MatrixResolvedUserAllowlist = {
entries: string[];
resolvedEntries: MatrixResolvedAllowlistEntry[];
};
function normalizeMatrixUserLookupEntry(raw: string): string {
return raw
.replace(/^matrix:/i, "")
@@ -41,6 +51,30 @@ function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] {
});
}
function listResolvedMatrixAllowlistEntries(params: {
entries: Array<string | number>;
resolvedMap: Map<string, { resolved: boolean; id?: string }>;
}): MatrixResolvedAllowlistEntry[] {
const resolvedEntries: MatrixResolvedAllowlistEntry[] = [];
const seen = new Set<string>();
for (const entry of params.entries) {
const input = String(entry).trim();
if (!input || seen.has(input)) {
continue;
}
seen.add(input);
const resolved = params.resolvedMap.get(input);
if (!resolved?.resolved || !resolved.id) {
continue;
}
const id = normalizeMatrixUserId(resolved.id);
if (isMatrixQualifiedUserId(id)) {
resolvedEntries.push({ input, id });
}
}
return resolvedEntries;
}
function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoomsConfig {
const nextEntries: MatrixRoomsConfig = { ...entries };
for (const [roomKey, roomConfig] of Object.entries(entries)) {
@@ -119,10 +153,10 @@ async function resolveMatrixMonitorUserAllowlist(params: {
list?: Array<string | number>;
runtime: RuntimeEnv;
resolveTargets: ResolveMatrixTargetsFn;
}): Promise<string[]> {
}): Promise<MatrixResolvedUserAllowlist> {
const allowList = (params.list ?? []).map(String);
if (allowList.length === 0) {
return allowList;
return { entries: allowList, resolvedEntries: [] };
}
const resolution = await resolveMatrixMonitorUserEntries({
@@ -144,7 +178,13 @@ async function resolveMatrixMonitorUserAllowlist(params: {
);
}
return filterResolvedMatrixAllowlistEntries(canonicalized);
return {
entries: filterResolvedMatrixAllowlistEntries(canonicalized),
resolvedEntries: listResolvedMatrixAllowlistEntries({
entries: allowList,
resolvedMap: resolution.resolvedMap,
}),
};
}
async function resolveMatrixMonitorRoomsConfig(params: {
@@ -264,7 +304,9 @@ export async function resolveMatrixMonitorConfig(params: {
resolveTargets?: ResolveMatrixTargetsFn;
}): Promise<{
allowFrom: string[];
allowFromResolvedEntries: MatrixResolvedAllowlistEntry[];
groupAllowFrom: string[];
groupAllowFromResolvedEntries: MatrixResolvedAllowlistEntry[];
roomsConfig?: MatrixRoomsConfig;
}> {
const resolveTargets = params.resolveTargets ?? resolveMatrixTargets;
@@ -296,8 +338,10 @@ export async function resolveMatrixMonitorConfig(params: {
]);
return {
allowFrom,
groupAllowFrom,
allowFrom: allowFrom.entries,
allowFromResolvedEntries: allowFrom.resolvedEntries,
groupAllowFrom: groupAllowFrom.entries,
groupAllowFromResolvedEntries: groupAllowFrom.resolvedEntries,
roomsConfig,
};
}

View File

@@ -22,7 +22,9 @@ type MatrixHandlerTestHarnessOptions = {
logger?: RuntimeLogger;
logVerboseMessage?: (message: string) => void;
allowFrom?: string[];
allowFromResolvedEntries?: MatrixMonitorHandlerParams["allowFromResolvedEntries"];
groupAllowFrom?: string[];
groupAllowFromResolvedEntries?: MatrixMonitorHandlerParams["groupAllowFromResolvedEntries"];
roomsConfig?: Record<string, MatrixRoomConfig>;
accountAllowBots?: boolean | "mentions";
configuredBotUserIds?: Set<string>;
@@ -115,6 +117,7 @@ export function createMatrixHandlerTestHarness(
counts: { final: 0, block: 0, tool: 0 },
}));
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
const cfgForHandler = options.cfg ?? {};
const handler = createMatrixRoomMessageHandler({
client: {
@@ -123,6 +126,9 @@ export function createMatrixHandlerTestHarness(
...options.client,
} as never,
core: {
config: {
loadConfig: () => cfgForHandler,
},
channel: {
pairing: {
readAllowFromStore,
@@ -193,7 +199,7 @@ export function createMatrixHandlerTestHarness(
enqueueSystemEvent,
},
} as never,
cfg: (options.cfg ?? {}) as never,
cfg: cfgForHandler as never,
accountId: options.accountId ?? "ops",
runtime:
options.runtime ??
@@ -209,7 +215,9 @@ export function createMatrixHandlerTestHarness(
} as RuntimeLogger),
logVerboseMessage: options.logVerboseMessage ?? (() => {}),
allowFrom: options.allowFrom ?? [],
allowFromResolvedEntries: options.allowFromResolvedEntries,
groupAllowFrom: options.groupAllowFrom ?? [],
groupAllowFromResolvedEntries: options.groupAllowFromResolvedEntries,
roomsConfig: options.roomsConfig,
accountAllowBots: options.accountAllowBots,
configuredBotUserIds: options.configuredBotUserIds,

View File

@@ -1741,6 +1741,261 @@ describe("matrix monitor handler pairing account scope", () => {
});
});
describe("matrix monitor handler live allowlist reload", () => {
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
const createDispatchReplyFromConfig = () =>
vi.fn(async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
}));
const sendLiveAllowlistMessage = async (
handler: MatrixHandler,
params: {
eventId: string;
sender: string;
body: string;
roomId?: string;
mentions?: MatrixRawEvent["content"]["m.mentions"];
},
) => {
await handler(
params.roomId ?? "!dm:example.org",
createMatrixTextMessageEvent({
eventId: params.eventId,
sender: params.sender,
body: params.body,
...(params.mentions ? { mentions: params.mentions } : {}),
}),
);
};
it("accepts a DM sender added to live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
dm: { allowFrom: [] as string[] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-add-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-add-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("blocks a DM sender removed from live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["@alice:example.org"] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-remove-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-remove-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("blocks a DM sender after live wildcard removal", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["*"] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: ["*"],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-wildcard-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-wildcard-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("uses account-scoped live dm.allowFrom overrides", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["@base:example.org"] },
accounts: {
ops: {
dm: { allowFrom: ["@alice:example.org"] },
},
},
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
accountId: "ops",
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-account-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-account-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("keeps startup-resolved display names only while the raw input remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["Alice"] },
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
dmPolicy: "allowlist",
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-name-before",
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
eventId: "$dm-name-after",
sender: "@alice:example.org",
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const cfg = {
channels: {
matrix: {
groupAllowFrom: ["@alice:example.org", "@bob:example.org"],
},
},
};
const { handler } = createMatrixHandlerTestHarness({
cfg,
isDirectMessage: false,
groupPolicy: "allowlist",
roomsConfig: { "*": {} },
groupAllowFrom: ["@alice:example.org", "@bob:example.org"],
groupAllowFromResolvedEntries: [
{ input: "@alice:example.org", id: "@alice:example.org" },
{ input: "@bob:example.org", id: "@bob:example.org" },
],
dispatchReplyFromConfig,
});
await sendLiveAllowlistMessage(handler, {
roomId: "!room:example.org",
eventId: "$group-remove-before",
sender: "@alice:example.org",
body: "@room hello",
mentions: { room: true },
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
await sendLiveAllowlistMessage(handler, {
roomId: "!room:example.org",
eventId: "$group-remove-after",
sender: "@alice:example.org",
body: "@room hello again",
mentions: { room: true },
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
});
describe("matrix monitor handler durable inbound dedupe", () => {
it("skips replayed inbound events before session recording", async () => {
const inboundDeduper = {

View File

@@ -14,6 +14,7 @@ import type {
MatrixStreamingMode,
ReplyToMode,
} from "../../types.js";
import { resolveMatrixAccountAllowlistConfig } from "../account-config.js";
import { formatMatrixErrorMessage } from "../errors.js";
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
import {
@@ -33,9 +34,11 @@ import {
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
import { isMatrixQualifiedUserId } from "../target-ids.js";
import { resolveMatrixMonitorAccessState } from "./access-state.js";
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
import { resolveMatrixAllowListMatch } from "./allowlist.js";
import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js";
import type { MatrixResolvedAllowlistEntry } from "./config.js";
import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
@@ -151,7 +154,9 @@ export type MatrixMonitorHandlerParams = {
logger: RuntimeLogger;
logVerboseMessage: (message: string) => void;
allowFrom: string[];
allowFromResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
groupAllowFrom?: string[];
groupAllowFromResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
roomsConfig?: Record<string, MatrixRoomConfig>;
accountAllowBots?: boolean | "mentions";
configuredBotUserIds?: ReadonlySet<string>;
@@ -188,6 +193,61 @@ export type MatrixMonitorHandlerParams = {
needsRoomAliasesForConfig: boolean;
};
function normalizeConfiguredMatrixAllowlistEntries(
entries?: ReadonlyArray<string | number>,
): string[] {
const normalized: string[] = [];
for (const entry of entries ?? []) {
const trimmed = String(entry).trim();
if (trimmed) {
normalized.push(trimmed);
}
}
return normalized;
}
function isMatrixHotReloadAllowlistEntry(entry: string): boolean {
if (entry === "*") {
return true;
}
return isMatrixQualifiedUserId(normalizeMatrixUserId(entry));
}
function resolveEffectiveMatrixLiveAllowlist(params: {
liveEntries?: ReadonlyArray<string | number>;
startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
}): string[] {
const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.liveEntries);
const liveInputs = new Set(liveEntries);
const effective: string[] = [];
const seen = new Set<string>();
const add = (entry: string) => {
const trimmed = entry.trim();
if (!trimmed) {
return;
}
const key = trimmed.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
effective.push(trimmed);
};
for (const entry of liveEntries) {
if (isMatrixHotReloadAllowlistEntry(entry)) {
add(entry);
}
}
for (const entry of params.startupResolvedEntries ?? []) {
if (liveInputs.has(entry.input)) {
add(entry.id);
}
}
return effective;
}
function resolveMatrixMentionPrecheckText(params: {
eventType: string;
content: RoomMessageEventContent;
@@ -354,8 +414,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
runtime,
logger,
logVerboseMessage,
allowFrom,
groupAllowFrom = [],
allowFromResolvedEntries = [],
groupAllowFromResolvedEntries = [],
roomsConfig,
accountAllowBots,
configuredBotUserIds = new Set<string>(),
@@ -638,10 +698,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
};
const storeAllowFrom = isDirectMessage ? await readStoreAllowFrom() : [];
const roomUsers = roomConfig?.users ?? [];
const liveAccountAllowlists = resolveMatrixAccountAllowlistConfig({
cfg: core.config.loadConfig() as CoreConfig,
accountId,
});
const liveDmAllowFrom = resolveEffectiveMatrixLiveAllowlist({
liveEntries: liveAccountAllowlists.dmAllowFrom,
startupResolvedEntries: allowFromResolvedEntries,
});
const liveGroupAllowFrom = resolveEffectiveMatrixLiveAllowlist({
liveEntries: liveAccountAllowlists.groupAllowFrom,
startupResolvedEntries: groupAllowFromResolvedEntries,
});
const accessState = resolveMatrixMonitorAccessState({
allowFrom,
allowFrom: liveDmAllowFrom,
storeAllowFrom,
groupAllowFrom,
groupAllowFrom: liveGroupAllowFrom,
roomUsers,
senderId,
isRoom,

View File

@@ -34,7 +34,7 @@ import {
} from "../sync-state.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { resolveMatrixMonitorConfig } from "./config.js";
import { resolveMatrixMonitorConfig, type MatrixResolvedAllowlistEntry } from "./config.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
@@ -112,6 +112,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const accountAllowBots = accountConfig.allowBots;
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
let allowFromResolvedEntries: MatrixResolvedAllowlistEntry[] = [];
let groupAllowFromResolvedEntries: MatrixResolvedAllowlistEntry[] = [];
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
let needsRoomAliasesForConfig = false;
const configuredBotUserIds = resolveConfiguredMatrixBotUserIds({
@@ -119,7 +121,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
accountId: effectiveAccountId,
});
({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({
({
allowFrom,
allowFromResolvedEntries,
groupAllowFrom,
groupAllowFromResolvedEntries,
roomsConfig,
} = await resolveMatrixMonitorConfig({
cfg,
accountId: effectiveAccountId,
allowFrom,
@@ -320,7 +328,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
logger,
logVerboseMessage,
allowFrom,
allowFromResolvedEntries,
groupAllowFrom,
groupAllowFromResolvedEntries,
roomsConfig,
accountAllowBots,
configuredBotUserIds,