diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc52e48c7f..5d4d35f7273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/matrix/src/matrix/account-config.ts b/extensions/matrix/src/matrix/account-config.ts index 0297c625c86..2ce95a0f679 100644 --- a/extensions/matrix/src/matrix/account-config.ts +++ b/extensions/matrix/src/matrix/account-config.ts @@ -148,3 +148,28 @@ export function resolveMatrixAccountConfig(params: { ...(rooms ? { rooms } : {}), }; } + +export function resolveMatrixAccountAllowlistConfig(params: { + cfg: CoreConfig; + accountId?: string | null; +}): { + dmAllowFrom?: NonNullable["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 }; +} diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts index c345e12cb02..c2bade49d7b 100644 --- a/extensions/matrix/src/matrix/monitor/config.ts +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -14,6 +14,16 @@ import { type MatrixRoomsConfig = Record; 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; + resolvedMap: Map; +}): MatrixResolvedAllowlistEntry[] { + const resolvedEntries: MatrixResolvedAllowlistEntry[] = []; + const seen = new Set(); + 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; runtime: RuntimeEnv; resolveTargets: ResolveMatrixTargetsFn; -}): Promise { +}): Promise { 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, }; } diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 00c740c7a16..7ec593ec38b 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -22,7 +22,9 @@ type MatrixHandlerTestHarnessOptions = { logger?: RuntimeLogger; logVerboseMessage?: (message: string) => void; allowFrom?: string[]; + allowFromResolvedEntries?: MatrixMonitorHandlerParams["allowFromResolvedEntries"]; groupAllowFrom?: string[]; + groupAllowFromResolvedEntries?: MatrixMonitorHandlerParams["groupAllowFromResolvedEntries"]; roomsConfig?: Record; accountAllowBots?: boolean | "mentions"; configuredBotUserIds?: Set; @@ -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, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 300e8445c0f..f2751f32682 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -1741,6 +1741,261 @@ describe("matrix monitor handler pairing account scope", () => { }); }); +describe("matrix monitor handler live allowlist reload", () => { + type MatrixHandler = ReturnType["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 = { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 24ad2c9f79f..57792075810 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -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; accountAllowBots?: boolean | "mentions"; configuredBotUserIds?: ReadonlySet; @@ -188,6 +193,61 @@ export type MatrixMonitorHandlerParams = { needsRoomAliasesForConfig: boolean; }; +function normalizeConfiguredMatrixAllowlistEntries( + entries?: ReadonlyArray, +): 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; + startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[]; +}): string[] { + const liveEntries = normalizeConfiguredMatrixAllowlistEntries(params.liveEntries); + const liveInputs = new Set(liveEntries); + const effective: string[] = []; + const seen = new Set(); + 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(), @@ -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, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index cb07f09e07f..be66487e004 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -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,