diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd70a1ef03..d87f58cc1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Gateway/Windows: read listener command lines via PowerShell before falling back to `wmic`, so restart health can recognize OpenClaw listeners on modern Windows installs and avoid long anonymous-port waits. Refs #74280. Thanks @zym951223. - Plugins/runtime-deps: memoize packaged bundled runtime dist-mirror preparation after the first successful pass while keeping source-checkout mirrors refreshable, so constrained Docker/VPS installs avoid repeated root scans before chat turns. Refs #73428, #73421, #73532, and #73477. Thanks @Dimaoggg, @oromeis, @oadiazp, @jmfraga, @bstanbury, @antoniusfelix, and @jkobject. - Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie. +- Channels/Discord/Slack: share one DM policy/allowlist resolver across runtime, setup, allowlist editing, and doctor repair, so legacy `dm.policy` / `dm.allowFrom` compatibility migrates to canonical `dmPolicy` / `allowFrom` without divergent access checks. Thanks @Squirbie. - Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev. - Agents/usage: keep PI embedded-run telemetry attributed to the resolved model provider instead of the PI harness label, so OpenRouter and other provider-backed turns report the right provider in session usage and traces. Thanks @vincentkoc. - Agents/attribution: send OpenClaw attribution headers on native OpenAI and Codex traffic, including SDK transports, realtime voice and TTS, device-code auth, WHAM usage, and remote embeddings, so PI-origin defaults no longer leak into provider requests. Thanks @vincentkoc. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ae7003351c9..8f228f556c6 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -390,11 +390,11 @@ Example: - `channels.discord.dmPolicy` controls DM access (legacy: `channels.discord.dm.policy`): + `channels.discord.dmPolicy` controls DM access. `channels.discord.allowFrom` is the canonical DM allowlist. - `pairing` (default) - `allowlist` - - `open` (requires `channels.discord.allowFrom` to include `"*"`; legacy: `channels.discord.dm.allowFrom`) + - `open` (requires `channels.discord.allowFrom` to include `"*"`) - `disabled` If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode). @@ -406,6 +406,8 @@ Example: - Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` and legacy `dm.allowFrom` are unset. - Named accounts do not inherit `channels.discord.accounts.default.allowFrom`. + Legacy `channels.discord.dm.policy` and `channels.discord.dm.allowFrom` still read for compatibility. `openclaw doctor --fix` migrates them to `dmPolicy` and `allowFrom` when it can do so without changing access. + DM target format for delivery: - `user:` diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 8675f51af38..aef6348183e 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -464,17 +464,17 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r - `channels.slack.dmPolicy` controls DM access (legacy: `channels.slack.dm.policy`): + `channels.slack.dmPolicy` controls DM access. `channels.slack.allowFrom` is the canonical DM allowlist. - `pairing` (default) - `allowlist` - - `open` (requires `channels.slack.allowFrom` to include `"*"`; legacy: `channels.slack.dm.allowFrom`) + - `open` (requires `channels.slack.allowFrom` to include `"*"`) - `disabled` DM flags: - `dm.enabled` (default true) - - `channels.slack.allowFrom` (preferred) + - `channels.slack.allowFrom` - `dm.allowFrom` (legacy) - `dm.groupEnabled` (group DMs default false) - `dm.groupChannels` (optional MPIM allowlist) @@ -485,6 +485,8 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r - Named accounts inherit `channels.slack.allowFrom` when their own `allowFrom` is unset. - Named accounts do not inherit `channels.slack.accounts.default.allowFrom`. + Legacy `channels.slack.dm.policy` and `channels.slack.dm.allowFrom` still read for compatibility. `openclaw doctor --fix` migrates them to `dmPolicy` and `allowFrom` when it can do so without changing access. + Pairing in DMs uses `openclaw pairing approve slack `. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 2f823ae268b..0d767819844 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -472,6 +472,8 @@ should use `resolveInboundMentionDecision({ facts, policy })`. }); ``` + For channels that accept both canonical top-level DM keys and legacy nested keys, use the helpers from `plugin-sdk/channel-config-helpers`: `resolveChannelDmAccess`, `resolveChannelDmPolicy`, `resolveChannelDmAllowFrom`, and `normalizeChannelDmPolicy` keep account-local values ahead of inherited root values. Pair the same resolver with doctor repair through `normalizeLegacyDmAliases` so runtime and migration read the same contract. + Instead of implementing low-level adapter interfaces manually, you pass declarative options and the builder composes them: diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 8eb3024b708..9ce2c8d3f72 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -387,7 +387,7 @@ releases. | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` | | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | Reply prefix, typing, and source-delivery wiring | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` | - | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | + | `plugin-sdk/channel-config-helpers` | Config adapter factories and DM access helpers | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` | | `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives and the generic builder only | | `plugin-sdk/bundled-channel-config-schema` | Bundled config schemas | OpenClaw-maintained bundled plugins only; new plugins must define plugin-local schemas | | `plugin-sdk/channel-config-schema-legacy` | Deprecated bundled config schemas | Compatibility alias only; use `plugin-sdk/bundled-channel-config-schema` for maintained bundled plugins | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 58bdaac3248..cb2180cde61 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -57,7 +57,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers | | `plugin-sdk/channel-pairing` | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` | - | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` | + | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` | | `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder | | `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only | | `plugin-sdk/channel-config-schema-legacy` | Deprecated compatibility alias for bundled-channel config schemas | diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index f296d9f3ca0..a9890d78075 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -4,6 +4,12 @@ import { resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-helpers"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + normalizeChannelDmPolicy, + resolveChannelDmAllowFrom, + resolveChannelDmPolicy, + type ChannelDmPolicy, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js"; @@ -52,14 +58,27 @@ export function resolveDiscordAccountAllowFrom(params: { const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined; - // New allowFrom wins over legacy dm.allowFrom within the same scope, while an - // account-local legacy list still wins over a root allowFrom inherited by merge. - return ( - accountConfig?.allowFrom ?? - accountConfig?.dm?.allowFrom ?? - rootConfig?.allowFrom ?? - rootConfig?.dm?.allowFrom + return resolveChannelDmAllowFrom({ + account: accountConfig as Record | undefined, + parent: rootConfig as Record | undefined, + }) as string[] | undefined; +} + +export function resolveDiscordAccountDmPolicy(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ChannelDmPolicy | undefined { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultDiscordAccountId(params.cfg), ); + const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); + const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined; + const policy = resolveChannelDmPolicy({ + account: accountConfig as Record | undefined, + parent: rootConfig as Record | undefined, + defaultPolicy: "pairing", + }); + return normalizeChannelDmPolicy(policy); } export function createDiscordActionGate(params: { diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index f93841f5576..882a92b90d9 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -489,6 +489,8 @@ describe("discordPlugin security", () => { expect(result.policy).toBe("allowlist"); expect(result.allowFrom).toEqual([" discord:<@!123456789> "]); + expect(result.policyPath).toBe("channels.discord.dmPolicy"); + expect(result.allowFromPath).toBe("channels.discord."); expect(result.normalizeEntry?.(" discord:<@!123456789> ")).toBe("123456789"); expect(result.normalizeEntry?.(" user:987654321 ")).toBe("987654321"); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 729d31af0e2..8b51487596a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -29,6 +29,7 @@ import { import { listDiscordAccountIds, resolveDiscordAccount, + resolveDiscordAccountAllowFrom, type ResolvedDiscordAccount, } from "./accounts.js"; import { getDiscordApprovalCapability } from "./approval-native.js"; @@ -373,7 +374,8 @@ export const discordPlugin: ChannelPlugin resolveAccount: resolveDiscordAccount, normalize: ({ cfg, accountId, values }) => discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveDmAllowFrom: (account, { cfg }) => + resolveDiscordAccountAllowFrom({ cfg, accountId: account.accountId }), resolveGroupPolicy: (account) => account.config.groupPolicy, resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides, }), diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 8a462005517..a29604f42c2 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -219,14 +219,12 @@ export function normalizeCompatibilityConfig({ const changes: string[] = []; let updated = rawEntry; let changed = false; - const shouldPromoteRootDmAllowFrom = !asObjectRecord(updated.accounts); const aliases = normalizeLegacyChannelAliases({ entry: rawEntry, pathPrefix: "channels.discord", changes, normalizeDm: true, - rootDmPromoteAllowFrom: shouldPromoteRootDmAllowFrom, normalizeAccountDm: true, resolveStreamingOptions: (entry) => ({ resolvedMode: resolveDiscordPreviewStreamMode(entry), diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts index 4122fcb9945..4beca38d87b 100644 --- a/extensions/discord/src/doctor.ts +++ b/extensions/discord/src/doctor.ts @@ -300,7 +300,7 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { } export const discordDoctor: ChannelDoctorAdapter = { - dmAllowFromMode: "topOrNested", + dmAllowFromMode: "topOnly", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, diff --git a/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts index ff666c71c13..e284ed20590 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test-helpers.ts @@ -99,6 +99,7 @@ export function createDiscordPreflightArgs(params: { replyToMode: "all", dmEnabled: true, groupDmEnabled: true, + dmPolicy: params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing", ackReactionScope: "direct", groupPolicy: "open", threadBindings: createNoopThreadBindingManager("default"), diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 5f6c31757cf..e4d01347283 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -220,7 +220,7 @@ export async function preflightDiscordMessage( return null; } - const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing"; + const dmPolicy = params.dmPolicy; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg); const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig); diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 848016a3881..24cd4cf9759 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -97,6 +97,7 @@ export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields dmEnabled: boolean; groupDmEnabled: boolean; groupDmChannels?: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; allowFrom?: string[]; guildEntries?: Record; ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"]; diff --git a/extensions/discord/src/monitor/message-handler.test-helpers.ts b/extensions/discord/src/monitor/message-handler.test-helpers.ts index fd52186b247..5b9557eb658 100644 --- a/extensions/discord/src/monitor/message-handler.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.test-helpers.ts @@ -43,6 +43,7 @@ export function createDiscordHandlerParams(overrides?: { textLimit: 2_000, replyToMode: "off" as const, dmEnabled: true, + dmPolicy: "pairing", groupDmEnabled: false, threadBindings: createNoopThreadBindingManager("default"), setStatus: overrides?.setStatus, diff --git a/extensions/discord/src/monitor/native-command-auth.ts b/extensions/discord/src/monitor/native-command-auth.ts index 4c2c987bf3c..41ad79a71e6 100644 --- a/extensions/discord/src/monitor/native-command-auth.ts +++ b/extensions/discord/src/monitor/native-command-auth.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { resolveDiscordAccountAllowFrom, resolveDiscordAccountDmPolicy } from "../accounts.js"; import type { AutocompleteInteraction } from "../internal/discord.js"; import { normalizeDiscordAllowList, @@ -183,8 +184,13 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { : []; const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig); const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const configuredDmAllowFrom = + resolveDiscordAccountAllowFrom({ + cfg, + accountId, + }) ?? []; const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [], + allowFrom: configuredDmAllowFrom, sender: { id: sender.id, name: sender.name, @@ -249,7 +255,7 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { } } const dmEnabled = discordConfig?.dm?.enabled ?? true; - const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing"; + const dmPolicy = resolveDiscordAccountDmPolicy({ cfg, accountId }) ?? "pairing"; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { return false; @@ -257,7 +263,7 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { const dmAccess = await resolveDiscordDmCommandAccess({ accountId, dmPolicy, - configuredAllowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [], + configuredAllowFrom: configuredDmAllowFrom, sender: { id: sender.id, name: sender.name, diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 63b0a4aa73a..728e5381578 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -23,7 +23,11 @@ import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch- import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; +import { + resolveDiscordAccountAllowFrom, + resolveDiscordAccountDmPolicy, + resolveDiscordMaxLinesPerMessage, +} from "../accounts.js"; import { Button, Command, @@ -312,8 +316,13 @@ async function dispatchDiscordCommandInteraction(params: { ? interaction.rawData.member.roles.map((roleId: string) => roleId) : []; const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig); + const configuredDmAllowFrom = + resolveDiscordAccountAllowFrom({ + cfg, + accountId, + }) ?? []; const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [], + allowFrom: configuredDmAllowFrom, sender: { id: sender.id, name: sender.name, @@ -414,7 +423,7 @@ async function dispatchDiscordCommandInteraction(params: { } } const dmEnabled = discordConfig?.dm?.enabled ?? true; - const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing"; + const dmPolicy = resolveDiscordAccountDmPolicy({ cfg, accountId }) ?? "pairing"; let commandAuthorized = true; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { @@ -424,7 +433,7 @@ async function dispatchDiscordCommandInteraction(params: { const dmAccess = await resolveDiscordDmCommandAccess({ accountId, dmPolicy, - configuredAllowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [], + configuredAllowFrom: configuredDmAllowFrom, sender: { id: sender.id, name: sender.name, diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index dc147023c65..1284fe96a09 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -22,7 +22,11 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; -import { resolveDiscordAccount } from "../accounts.js"; +import { + resolveDiscordAccount, + resolveDiscordAccountAllowFrom, + resolveDiscordAccountDmPolicy, +} from "../accounts.js"; import { Client } from "../internal/discord.js"; import { GatewayCloseCodes } from "../internal/gateway.js"; import { fetchDiscordApplicationId, parseApplicationIdFromToken } from "../probe.js"; @@ -201,6 +205,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); const discordProxyFetch = resolveDiscordProxyFetchForAccount(account, cfg, runtime); const dmConfig = rawDiscordCfg.dm; + const configuredDmAllowFrom = resolveDiscordAccountAllowFrom({ + cfg, + accountId: account.accountId, + }); let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const providerConfigPresent = cfg.channels?.discord !== undefined; @@ -218,7 +226,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { blockedLabel: GROUP_POLICY_BLOCKED_LABEL.guild, log: (message) => runtime.log?.(warn(message)), }); - let allowFrom = discordCfg.allowFrom ?? dmConfig?.allowFrom; + let allowFrom = configuredDmAllowFrom ?? []; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? DEFAULT_DISCORD_MEDIA_MAX_MB) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { @@ -230,7 +238,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ); const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; - const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; + const dmPolicy = + resolveDiscordAccountDmPolicy({ + cfg, + accountId: account.accountId, + }) ?? "pairing"; const discordProviderSessionRuntime = await loadDiscordProviderSessionRuntime(); const threadBindingIdleTimeoutMs = discordProviderSessionRuntime.resolveThreadBindingIdleTimeoutMs({ @@ -277,7 +289,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime, }); guildEntries = allowlistResolved.guildEntries; - allowFrom = allowlistResolved.allowFrom; + allowFrom = allowlistResolved.allowFrom ?? []; if ((shouldLogVerboseForTesting ?? shouldLogVerbose)()) { logDiscordResolvedConfig({ @@ -535,6 +547,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { textLimit, replyToMode, dmEnabled, + dmPolicy, groupDmEnabled, groupDmChannels, allowFrom, diff --git a/extensions/discord/src/security.ts b/extensions/discord/src/security.ts index 586c9f26820..f3a82a7aca0 100644 --- a/extensions/discord/src/security.ts +++ b/extensions/discord/src/security.ts @@ -1,13 +1,21 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import type { ResolvedDiscordAccount } from "./accounts.js"; +import { + resolveDiscordAccountAllowFrom, + resolveDiscordAccountDmPolicy, + type ResolvedDiscordAccount, +} from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; const resolveDiscordDmPolicy = createScopedDmSecurityResolver({ channelKey: "discord", - resolvePolicy: (account) => account.config.dm?.policy, - resolveAllowFrom: (account) => account.config.dm?.allowFrom, - allowFromPathSuffix: "dm.", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + resolveAccess: ({ cfg, account }) => ({ + dmPolicy: resolveDiscordAccountDmPolicy({ cfg, accountId: account.accountId }), + allowFrom: resolveDiscordAccountAllowFrom({ cfg, accountId: account.accountId }), + }), + policyPathSuffix: "dmPolicy", normalizeEntry: (raw) => raw .trim() diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index e8bb4bc2d9c..36914884657 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -4,6 +4,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { resolveDiscordAccountAllowFrom } from "./accounts.js"; import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { @@ -68,10 +69,8 @@ async function promptDiscordAllowFrom(params: { placeholder: "@alice, 123456789012345678", parseId: parseDiscordAllowFromId, invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveExisting: (account) => { - const config = account.config; - return config.allowFrom ?? config.dm?.allowFrom ?? []; - }, + resolveExisting: (account, cfg) => + resolveDiscordAccountAllowFrom({ cfg, accountId: account.accountId }) ?? [], resolveToken: (account) => resolveDiscordToken(params.cfg, { accountId: account.accountId }).token, resolveEntries: async ({ token, entries }) => diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index b6bc1c27bcd..098cf885367 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -45,7 +45,7 @@ async function loadDiscordDoctorModule(): Promise { } const discordDoctor: ChannelDoctorAdapter = { - dmAllowFromMode: "topOrNested", + dmAllowFromMode: "topOnly", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 7c094e85f47..c44cf0f1654 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -98,9 +98,7 @@ export class DiscordVoiceManager { this.botUserId = params.botUserId; this.voiceEnabled = params.discordConfig.voice?.enabled !== false; this.ownerAllowFrom = - resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ?? - params.discordConfig.allowFrom ?? - params.discordConfig.dm?.allowFrom; + resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ?? []; this.speakerContext = new DiscordVoiceSpeakerContextResolver({ client: params.client, ownerAllowFrom: this.ownerAllowFrom, diff --git a/extensions/slack/src/accounts.test.ts b/extensions/slack/src/accounts.test.ts index a8104a2300e..bb809ecc627 100644 --- a/extensions/slack/src/accounts.test.ts +++ b/extensions/slack/src/accounts.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; -import { resolveSlackAccount } from "./accounts.js"; +import { resolveSlackAccount, resolveSlackAccountAllowFrom } from "./accounts.js"; describe("resolveSlackAccount allowFrom precedence", () => { it("uses configured defaultAccount when accountId is omitted", () => { @@ -107,6 +107,25 @@ describe("resolveSlackAccount allowFrom precedence", () => { expect(resolved.config.allowFrom).toBeUndefined(); expect(resolved.config.dm?.allowFrom).toEqual(["U123"]); }); + + it("resolves account legacy dm.allowFrom before inherited root allowFrom", () => { + const cfg = { + channels: { + slack: { + allowFrom: ["root"], + accounts: { + work: { + botToken: "xoxb-work", + appToken: "xapp-work", + dm: { allowFrom: ["account-legacy"] }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["account-legacy"]); + }); }); describe("resolveSlackAccount active secret surfaces", () => { diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 27042067876..c03842607ff 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -5,6 +5,12 @@ import { resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; +import { + normalizeChannelDmPolicy, + resolveChannelDmAllowFrom, + resolveChannelDmPolicy, + type ChannelDmPolicy, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import type { SlackAccountConfig } from "./runtime-api.js"; @@ -42,6 +48,38 @@ export function mergeSlackAccountConfig( }); } +export function resolveSlackAccountAllowFrom(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] | undefined { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSlackAccountId(params.cfg), + ); + const accountConfig = params.cfg.channels?.slack?.accounts?.[accountId]; + const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined; + return resolveChannelDmAllowFrom({ + account: accountConfig as Record | undefined, + parent: rootConfig as Record | undefined, + }) as string[] | undefined; +} + +export function resolveSlackAccountDmPolicy(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ChannelDmPolicy | undefined { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSlackAccountId(params.cfg), + ); + const accountConfig = params.cfg.channels?.slack?.accounts?.[accountId]; + const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined; + const policy = resolveChannelDmPolicy({ + account: accountConfig as Record | undefined, + parent: rootConfig as Record | undefined, + defaultPolicy: "pairing", + }); + return normalizeChannelDmPolicy(policy); +} + export function resolveSlackAccount(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/slack/src/approval-auth.ts b/extensions/slack/src/approval-auth.ts index f0484656602..1c6f871b9fe 100644 --- a/extensions/slack/src/approval-auth.ts +++ b/extensions/slack/src/approval-auth.ts @@ -3,7 +3,7 @@ import { resolveApprovalApprovers, } from "openclaw/plugin-sdk/approval-auth-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { resolveSlackAccount } from "./accounts.js"; +import { resolveSlackAccount, resolveSlackAccountAllowFrom } from "./accounts.js"; import { normalizeSlackApproverId } from "./exec-approvals.js"; export function getSlackApprovalApprovers(params: { @@ -12,8 +12,7 @@ export function getSlackApprovalApprovers(params: { }): string[] { const account = resolveSlackAccount(params).config; return resolveApprovalApprovers({ - allowFrom: account.allowFrom, - extraAllowFrom: account.dm?.allowFrom, + allowFrom: resolveSlackAccountAllowFrom(params), defaultTo: account.defaultTo, normalizeApprover: normalizeSlackApproverId, normalizeDefaultTo: normalizeSlackApproverId, diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 713a39fb7c4..bfce95a3376 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -8,6 +8,7 @@ import { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackAccountAllowFrom, } from "./accounts.js"; import { type ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; @@ -22,15 +23,30 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard, })); -const slackSetupConfigAdapter = createScopedChannelConfigAdapter({ +type SlackSetupConfigAccessorAccount = { + allowFrom: string[] | undefined; + defaultTo: string | undefined; +}; + +const slackSetupConfigAdapter = createScopedChannelConfigAdapter< + ResolvedSlackAccount, + SlackSetupConfigAccessorAccount +>({ sectionKey: SLACK_CHANNEL, listAccountIds: listSlackAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), + resolveAccessorAccount: (params) => { + const account = resolveSlackAccount(params); + return { + allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }), + defaultTo: account.config.defaultTo, + }; + }, defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], - resolveAllowFrom: (account) => account.dm?.allowFrom, + resolveAllowFrom: (account) => account.allowFrom, formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account) => account.config.defaultTo, + resolveDefaultTo: (account) => account.defaultTo, }); export const slackSetupPlugin: ChannelPlugin = { diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 8d7d7bd9ab3..16f6424c482 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -372,6 +372,8 @@ describe("slackPlugin security", () => { expect(result.policy).toBe("allowlist"); expect(result.allowFrom).toEqual([" slack:U123 "]); + expect(result.policyPath).toBe("channels.slack.dmPolicy"); + expect(result.allowFromPath).toBe("channels.slack."); expect(result.normalizeEntry?.(" slack:U123 ")).toBe("U123"); expect(result.normalizeEntry?.(" user:U999 ")).toBe("U999"); }); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 7be4621e20c..29444a3a3b2 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -24,6 +24,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackAccountAllowFrom, resolveSlackReplyToMode, type ResolvedSlackAccount, } from "./accounts.js"; @@ -371,7 +372,8 @@ export const slackPlugin: ChannelPlugin = crea resolveAccount: resolveSlackAccount, normalize: ({ cfg, accountId, values }) => slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveDmAllowFrom: (account, { cfg }) => + resolveSlackAccountAllowFrom({ cfg, accountId: account.accountId }), resolveGroupPolicy: (account) => account.groupPolicy, resolveGroupOverrides: resolveSlackAllowlistGroupOverrides, }), diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 44d7acce97a..a9da61d8b36 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -4,7 +4,11 @@ import { type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; +import { + mergeSlackAccountConfig, + resolveDefaultSlackAccountId, + resolveSlackAccountAllowFrom, +} from "./accounts.js"; import { parseSlackTarget } from "./targets.js"; function resolveSlackDirectoryConfigAccount( @@ -17,6 +21,7 @@ function resolveSlackDirectoryConfigAccount( accountId: resolvedAccountId, config, dm: config.dm, + allowFrom: resolveSlackAccountAllowFrom({ cfg, accountId: resolvedAccountId }) ?? [], }; } @@ -26,11 +31,10 @@ export const listSlackDirectoryPeersFromConfig = createResolvedDirectoryEntriesL kind: "user", resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId), resolveSources: (account) => { - const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; const channelUsers = Object.values(account.config.channels ?? {}).flatMap( (channel) => channel.users ?? [], ); - return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers]; + return [account.allowFrom, Object.keys(account.config.dms ?? {}), channelUsers]; }, normalizeId: (raw) => { const mention = raw.match(/^<@([A-Z0-9]+)>$/i); diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index ffafc9c6356..edc18ad3a3a 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -48,7 +48,7 @@ export const collectSlackMutableAllowlistWarnings = }); export const slackDoctor: ChannelDoctorAdapter = { - dmAllowFromMode: "topOrNested", + dmAllowFromMode: "topOnly", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 060da1a23a9..5327feebbe8 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -22,7 +22,7 @@ import { import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards"; -import { resolveSlackAccount } from "../accounts.js"; +import { resolveSlackAccount, resolveSlackAccountAllowFrom } from "../accounts.js"; import { resolveSlackWebClientOptions } from "../client-options.js"; import { isSlackExecApprovalClientEnabled } from "../exec-approvals.js"; import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js"; @@ -149,7 +149,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const dmEnabled = dmConfig?.enabled ?? true; const dmPolicy = slackCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; - let allowFrom = slackCfg.allowFrom ?? dmConfig?.allowFrom; + let allowFrom = resolveSlackAccountAllowFrom({ cfg, accountId: account.accountId }); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; let channelsConfig = slackCfg.channels; diff --git a/extensions/slack/src/security.ts b/extensions/slack/src/security.ts index b4630989c1e..d3510bfa0ab 100644 --- a/extensions/slack/src/security.ts +++ b/extensions/slack/src/security.ts @@ -1,14 +1,22 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import type { ResolvedSlackAccount } from "./accounts.js"; +import { + resolveSlackAccountAllowFrom, + resolveSlackAccountDmPolicy, + type ResolvedSlackAccount, +} from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; const resolveSlackDmPolicy = createScopedDmSecurityResolver({ channelKey: "slack", - resolvePolicy: (account) => account.dm?.policy, - resolveAllowFrom: (account) => account.dm?.allowFrom, - allowFromPathSuffix: "dm.", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + resolveAccess: ({ cfg, account }) => ({ + dmPolicy: resolveSlackAccountDmPolicy({ cfg, accountId: account.accountId }), + allowFrom: resolveSlackAccountAllowFrom({ cfg, accountId: account.accountId }), + }), + policyPathSuffix: "dmPolicy", normalizeEntry: (raw) => raw .trim() diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 71a69536477..c584f902798 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -16,6 +16,7 @@ import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackAccountAllowFrom, type ResolvedSlackAccount, } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; @@ -70,8 +71,8 @@ async function promptSlackAllowFrom(params: { accountId: params.accountId, defaultAccountId: resolveDefaultSlackAccountId(params.cfg), resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), - resolveExisting: (_account, cfg) => - cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom ?? [], + resolveExisting: (account, cfg) => + resolveSlackAccountAllowFrom({ cfg, accountId: account.accountId }) ?? [], resolveToken: (account) => account.userToken ?? account.botToken ?? "", noteTitle: "Slack allowlist", noteLines: [ diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index e008da6f892..499c13a2087 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -9,12 +9,14 @@ import { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackAccountAllowFrom, type ResolvedSlackAccount, } from "./accounts.js"; import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; import { slackDoctor } from "./doctor.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import type { OpenClawConfig } from "./runtime-api.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { slackSecurityAdapter } from "./security.js"; import { SLACK_CHANNEL } from "./setup-shared.js"; @@ -38,16 +40,36 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b return Boolean(account.appToken?.trim()); } -export const slackConfigAdapter = createScopedChannelConfigAdapter({ +type SlackConfigAccessorAccount = { + allowFrom: string[] | undefined; + defaultTo: string | undefined; +}; + +function resolveSlackConfigAccessorAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): SlackConfigAccessorAccount { + const account = resolveSlackAccount(params); + return { + allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }), + defaultTo: account.config.defaultTo, + }; +} + +export const slackConfigAdapter = createScopedChannelConfigAdapter< + ResolvedSlackAccount, + SlackConfigAccessorAccount +>({ sectionKey: SLACK_CHANNEL, listAccountIds: listSlackAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), + resolveAccessorAccount: resolveSlackConfigAccessorAccount, inspectAccount: adaptScopedAccountAccessor(inspectSlackAccount), defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + resolveAllowFrom: (account) => account.allowFrom, formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, + resolveDefaultTo: (account) => account.defaultTo, }); export function createSlackPluginBase(params: { diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 44f03a3d8f7..c318eaa13e1 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -6,6 +6,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; +import { resolveChannelDmAllowFrom, resolveChannelDmPolicy } from "./dm-access.js"; import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, @@ -565,7 +566,9 @@ export function setCompatChannelDmPolicyWithAllowFrom(params: { allowFrom: undefined, dm: undefined, }; - const existingAllowFrom = channelConfig.allowFrom ?? channelConfig.dm?.allowFrom; + const existingAllowFrom = resolveChannelDmAllowFrom({ + account: channelConfig as Record, + }); const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; return patchCompatDmChannelConfig({ @@ -651,13 +654,11 @@ export function createCompatChannelDmPolicy(params: { accountId && accountId !== DEFAULT_ACCOUNT_ID ? channelConfig.accounts?.[accountId] : undefined; - return ( - accountConfig?.dmPolicy ?? - accountConfig?.dm?.policy ?? - channelConfig.dmPolicy ?? - channelConfig.dm?.policy ?? - "pairing" - ); + return resolveChannelDmPolicy({ + account: accountConfig as Record | undefined, + parent: channelConfig as Record, + defaultPolicy: "pairing", + }) as DmPolicy; }, setPolicy: (cfg, policy, accountId) => accountId && accountId !== DEFAULT_ACCOUNT_ID @@ -670,44 +671,18 @@ export function createCompatChannelDmPolicy(params: { ...(policy === "open" ? { allowFrom: addWildcardAllowFrom( - ( - cfg.channels?.[params.channel] as - | { - accounts?: Record< - string, - { - allowFrom?: Array; - dm?: { allowFrom?: Array }; - } - >; - } - | undefined - )?.accounts?.[accountId]?.allowFrom ?? - ( + resolveChannelDmAllowFrom({ + account: ( cfg.channels?.[params.channel] as | { - allowFrom?: Array; - dm?: { allowFrom?: Array }; + accounts?: Record>; } | undefined - )?.allowFrom ?? - ( - cfg.channels?.[params.channel] as - | { - accounts?: Record< - string, - { dm?: { allowFrom?: Array } } - >; - } - | undefined - )?.accounts?.[accountId]?.dm?.allowFrom ?? - ( - cfg.channels?.[params.channel] as - | { - dm?: { allowFrom?: Array }; - } - | undefined - )?.dm?.allowFrom, + )?.accounts?.[accountId], + parent: cfg.channels?.[params.channel] as + | Record + | undefined, + }), ), } : {}), diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 1b1b9072a2e..c42895b5e63 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2525,7 +2525,7 @@ describe("doctor config flow", () => { }; }; expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]); - expect(cfg.channels.googlechat.allowFrom).toEqual(["*"]); + expect(cfg.channels.googlechat.allowFrom).toBeUndefined(); }); it("does not report repeat talk provider normalization on consecutive repair runs", async () => { diff --git a/src/commands/doctor/shared/allow-from-mode.types.ts b/src/commands/doctor/shared/allow-from-mode.types.ts index 7139ade0f0a..701f6f479ad 100644 --- a/src/commands/doctor/shared/allow-from-mode.types.ts +++ b/src/commands/doctor/shared/allow-from-mode.types.ts @@ -1 +1 @@ -export type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; +export type { ChannelDmAllowFromMode as AllowFromMode } from "../../../channels/plugins/dm-access.js"; diff --git a/src/commands/doctor/shared/allowlist-policy-repair.ts b/src/commands/doctor/shared/allowlist-policy-repair.ts index e724081aa6a..2de0c81f9c9 100644 --- a/src/commands/doctor/shared/allowlist-policy-repair.ts +++ b/src/commands/doctor/shared/allowlist-policy-repair.ts @@ -1,4 +1,5 @@ import { normalizeChatChannelId } from "../../../channels/ids.js"; +import { setCanonicalDmAllowFrom } from "../../../channels/plugins/dm-access.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; @@ -28,41 +29,14 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): }) => { const count = params.allowFrom.length; const noun = count === 1 ? "entry" : "entries"; - - if (params.mode === "nestedOnly") { - const dmEntry = params.account.dm; - const dm = - dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) - ? (dmEntry as Record) - : {}; - dm.allowFrom = params.allowFrom; - params.account.dm = dm; - changes.push( - `- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, - ); - return; - } - - if (params.mode === "topOrNested") { - const dmEntry = params.account.dm; - const dm = - dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) - ? (dmEntry as Record) - : undefined; - const nestedAllowFrom = dm?.allowFrom as Array | undefined; - if (dm && !Array.isArray(params.account.allowFrom) && Array.isArray(nestedAllowFrom)) { - dm.allowFrom = params.allowFrom; - changes.push( - `- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, - ); - return; - } - } - - params.account.allowFrom = params.allowFrom; - changes.push( - `- ${params.prefix}.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, - ); + setCanonicalDmAllowFrom({ + entry: params.account, + mode: params.mode, + allowFrom: params.allowFrom, + pathPrefix: params.prefix, + changes, + reason: `restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`, + }); }; const recoverAllowFromForAccount = async (params: { diff --git a/src/commands/doctor/shared/open-policy-allowfrom.test.ts b/src/commands/doctor/shared/open-policy-allowfrom.test.ts index e404d27f730..b1d544ad4c8 100644 --- a/src/commands/doctor/shared/open-policy-allowfrom.test.ts +++ b/src/commands/doctor/shared/open-policy-allowfrom.test.ts @@ -79,10 +79,11 @@ describe("doctor open-policy allowFrom repair", () => { expect(result.changes).toEqual([ '- channels.discord.dmPolicy: set to "open" (migrated from channels.discord.dm.policy)', - '- channels.discord.dm.allowFrom: added "*" (required by dmPolicy="open")', + "- channels.discord.dm.allowFrom: removed after moving allowlist to channels.discord.allowFrom", + '- channels.discord.allowFrom: added "*" (required by dmPolicy="open")', ]); - expect(result.config.channels?.discord?.allowFrom).toBeUndefined(); - expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["123", "*"]); + expect(result.config.channels?.discord?.allowFrom).toEqual(["123", "*"]); + expect(result.config.channels?.discord?.dm).toBeUndefined(); }); it("appends wildcard to existing top-level allowFrom", () => { diff --git a/src/commands/doctor/shared/open-policy-allowfrom.ts b/src/commands/doctor/shared/open-policy-allowfrom.ts index 81a703e2977..3fe1d560231 100644 --- a/src/commands/doctor/shared/open-policy-allowfrom.ts +++ b/src/commands/doctor/shared/open-policy-allowfrom.ts @@ -1,13 +1,9 @@ +import { ensureOpenDmPolicyAllowFromWildcard } from "../../../channels/plugins/dm-access.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeOptionalString } from "../../../shared/string-coerce.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { resolveAllowFromMode, type AllowFromMode } from "./allow-from-mode.js"; import { asObjectRecord } from "./object.js"; -function hasWildcard(list?: Array) { - return list?.some((v) => normalizeOptionalString(String(v)) === "*") ?? false; -} - export function collectOpenPolicyAllowFromWarnings(params: { changes: string[]; doctorFixCommand: string; @@ -38,90 +34,12 @@ export function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { prefix: string, mode: AllowFromMode, ) => { - const dmEntry = account.dm; - const dm = - dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) - ? (dmEntry as Record) - : undefined; - const dmPolicy = - (account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined) ?? undefined; - const canCanonicalizeTopLevel = mode !== "nestedOnly"; - const hadNestedOpenPolicy = - canCanonicalizeTopLevel && account.dmPolicy === undefined && dm?.policy === "open"; - - if (dmPolicy !== "open") { - return; - } - - const topAllowFrom = account.allowFrom as Array | undefined; - const nestedAllowFrom = dm?.allowFrom as Array | undefined; - - if (hadNestedOpenPolicy) { - account.dmPolicy = "open"; - delete dm.policy; - changes.push(`- ${prefix}.dmPolicy: set to "open" (migrated from ${prefix}.dm.policy)`); - } - - if ( - canCanonicalizeTopLevel && - !Array.isArray(topAllowFrom) && - Array.isArray(nestedAllowFrom) && - hasWildcard(nestedAllowFrom) - ) { - account.allowFrom = [...nestedAllowFrom]; - delete dm?.allowFrom; - changes.push( - `- ${prefix}.allowFrom: moved ${hasWildcard(nestedAllowFrom) ? "wildcard " : ""}allowlist from ${prefix}.dm.allowFrom`, - ); - } - - if (dm && Object.keys(dm).length === 0) { - delete account.dm; - } - - if (mode === "nestedOnly") { - if (hasWildcard(nestedAllowFrom)) { - return; - } - if (dm && Array.isArray(nestedAllowFrom)) { - dm.allowFrom = [...nestedAllowFrom, "*"]; - changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`); - } else { - const nextDm = dm ?? {}; - nextDm.allowFrom = ["*"]; - account.dm = nextDm; - changes.push(`- ${prefix}.dm.allowFrom: set to ["*"] (required by dmPolicy="open")`); - } - return; - } - - if (mode === "topOrNested") { - if (hasWildcard(topAllowFrom) || hasWildcard(nestedAllowFrom)) { - return; - } - if (Array.isArray(topAllowFrom)) { - account.allowFrom = [...topAllowFrom, "*"]; - changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`); - } else if (dm && Array.isArray(nestedAllowFrom)) { - dm.allowFrom = [...nestedAllowFrom, "*"]; - changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`); - } else { - account.allowFrom = ["*"]; - changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`); - } - return; - } - - if (hasWildcard(topAllowFrom)) { - return; - } - if (Array.isArray(topAllowFrom)) { - account.allowFrom = [...topAllowFrom, "*"]; - changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`); - } else { - account.allowFrom = ["*"]; - changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`); - } + ensureOpenDmPolicyAllowFromWildcard({ + entry: account, + mode, + pathPrefix: prefix, + changes, + }); }; const nextChannels = next.channels as Record>; diff --git a/src/config/channel-compat-normalization.ts b/src/config/channel-compat-normalization.ts index f8902e9a0de..69068cc1e55 100644 --- a/src/config/channel-compat-normalization.ts +++ b/src/config/channel-compat-normalization.ts @@ -1,9 +1,10 @@ -import { normalizeStringEntries } from "../shared/string-normalization.js"; +import { + normalizeLegacyDmAliases, + type CompatMutationResult, +} from "../channels/plugins/dm-access.js"; -export type CompatMutationResult = { - entry: Record; - changed: boolean; -}; +export { normalizeLegacyDmAliases }; +export type { CompatMutationResult }; export type LegacyStreamingAliasOptions = { resolvedMode: string; @@ -44,96 +45,6 @@ function ensureNestedRecord(owner: Record, key: string): Record return {}; } -function allowFromListsMatch(left: unknown, right: unknown): boolean { - if (!Array.isArray(left) || !Array.isArray(right)) { - return false; - } - const normalizedLeft = normalizeStringEntries(left); - const normalizedRight = normalizeStringEntries(right); - if (normalizedLeft.length !== normalizedRight.length) { - return false; - } - return normalizedLeft.every((value, index) => value === normalizedRight[index]); -} - -export function normalizeLegacyDmAliases(params: { - entry: Record; - pathPrefix: string; - changes: string[]; - promoteAllowFrom?: boolean; -}): CompatMutationResult { - let changed = false; - let updated: Record = params.entry; - const rawDm = updated.dm; - const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; - let dmChanged = false; - - const topDmPolicy = updated.dmPolicy; - const legacyDmPolicy = dm?.policy; - if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { - updated = { ...updated, dmPolicy: legacyDmPolicy }; - changed = true; - if (dm) { - delete dm.policy; - dmChanged = true; - } - params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); - } else if ( - topDmPolicy !== undefined && - legacyDmPolicy !== undefined && - topDmPolicy === legacyDmPolicy - ) { - if (dm) { - delete dm.policy; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); - } - } - - if (params.promoteAllowFrom !== false) { - const topAllowFrom = updated.allowFrom; - const legacyAllowFrom = dm?.allowFrom; - if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { - updated = { ...updated, allowFrom: legacyAllowFrom }; - changed = true; - if (dm) { - delete dm.allowFrom; - dmChanged = true; - } - params.changes.push( - `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, - ); - } else if ( - topAllowFrom !== undefined && - legacyAllowFrom !== undefined && - allowFromListsMatch(topAllowFrom, legacyAllowFrom) - ) { - if (dm) { - delete dm.allowFrom; - dmChanged = true; - params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); - } - } - } - - if (dm && asObjectRecord(rawDm) && dmChanged) { - const keys = Object.keys(dm); - if (keys.length === 0) { - if (updated.dm !== undefined) { - const { dm: _ignored, ...rest } = updated; - updated = rest; - changed = true; - params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); - } - } else { - updated = { ...updated, dm }; - changed = true; - } - } - - return { entry: updated, changed }; -} - export function normalizeLegacyStreamingAliases( params: { entry: Record; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 8e7ecebd7e0..75aa99db887 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -286,12 +286,12 @@ export type DiscordAccountConfig = { /** Thread session behavior. */ thread?: DiscordThreadConfig; /** - * Alias for dm.policy (prefer this so it inherits cleanly via base->account shallow merge). + * Canonical DM policy key. Doctor migrates legacy channels.discord.dm.policy here. * Legacy key: channels.discord.dm.policy. */ dmPolicy?: DmPolicy; /** - * Alias for dm.allowFrom (prefer this so it inherits cleanly via base->account shallow merge). + * Canonical DM allowlist. Doctor migrates legacy channels.discord.dm.allowFrom here. * Legacy key: channels.discord.dm.allowFrom. */ allowFrom?: string[]; diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index ed13b881842..927b8d1b8bf 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -187,12 +187,12 @@ export type SlackAccountConfig = { actions?: SlackActionConfig; slashCommand?: SlackSlashCommandConfig; /** - * Alias for dm.policy (prefer this so it inherits cleanly via base->account shallow merge). + * Canonical DM policy key. Doctor migrates legacy channels.slack.dm.policy here. * Legacy key: channels.slack.dm.policy. */ dmPolicy?: DmPolicy; /** - * Alias for dm.allowFrom (prefer this so it inherits cleanly via base->account shallow merge). + * Canonical DM allowlist. Doctor migrates legacy channels.slack.dm.allowFrom here. * Legacy key: channels.slack.dm.allowFrom. */ allowFrom?: Array; diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index 8fd84f1b8f7..a2bff121e58 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -360,12 +360,13 @@ function buildAccountAllowlistAdapter(params: { resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; readConfig: ( account: ResolvedAccount, + context: { cfg: OpenClawConfig; accountId?: string | null }, ) => Awaited>>; }): Pick { return { supportsScope: params.supportsScope, readConfig: ({ cfg, accountId }) => - params.readConfig(params.resolveAccount({ cfg, accountId })), + params.readConfig(params.resolveAccount({ cfg, accountId }), { cfg, accountId }), applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ channelId: params.channelId, normalize: params.normalize, @@ -379,7 +380,10 @@ export function buildDmGroupAccountAllowlistAdapter(params: { channelId: ChannelId; resolveAccount: AllowlistAccountResolver; normalize: AllowlistNormalizer; - resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveDmAllowFrom: ( + account: ResolvedAccount, + context: { cfg: OpenClawConfig; accountId?: string | null }, + ) => Array | null | undefined; resolveGroupAllowFrom: (account: ResolvedAccount) => Array | null | undefined; resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined; resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; @@ -391,8 +395,8 @@ export function buildDmGroupAccountAllowlistAdapter(params: { normalize: params.normalize, supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", resolvePaths: resolveDmGroupAllowlistConfigPaths, - readConfig: (account) => ({ - dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + readConfig: (account, context) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account, context)), groupAllowFrom: readConfiguredAllowlistEntries(params.resolveGroupAllowFrom(account)), dmPolicy: params.resolveDmPolicy?.(account) ?? undefined, groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, @@ -406,7 +410,10 @@ export function buildLegacyDmAccountAllowlistAdapter(params: { channelId: ChannelId; resolveAccount: AllowlistAccountResolver; normalize: AllowlistNormalizer; - resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveDmAllowFrom: ( + account: ResolvedAccount, + context: { cfg: OpenClawConfig; accountId?: string | null }, + ) => Array | null | undefined; resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; }): Pick { @@ -416,8 +423,8 @@ export function buildLegacyDmAccountAllowlistAdapter(params: { normalize: params.normalize, supportsScope: ({ scope }) => scope === "dm", resolvePaths: resolveLegacyDmAllowlistConfigPaths, - readConfig: (account) => ({ - dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + readConfig: (account, context) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account, context)), groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, groupOverrides: params.resolveGroupOverrides?.(account), }), diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index c0d972c2383..c5e23f1e4f8 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -12,7 +12,10 @@ import { createTopLevelChannelConfigAdapter, createTopLevelChannelConfigBase, createHybridChannelConfigBase, + ensureOpenDmPolicyAllowFromWildcard, mapAllowFromEntries, + normalizeLegacyDmAliases, + resolveChannelDmAccess, resolveChannelConfigWrites, resolveOptionalConfigString, } from "./channel-config-helpers.js"; @@ -119,6 +122,52 @@ describe("resolveOptionalConfigString", () => { }); }); +describe("channel DM access helpers", () => { + it("resolves account legacy allowFrom before inherited root allowFrom", () => { + expect( + resolveChannelDmAccess({ + account: { dm: { allowFrom: ["account-legacy"] } }, + parent: { allowFrom: ["root"] }, + }), + ).toEqual({ allowFrom: ["account-legacy"], dmPolicy: undefined }); + }); + + it("keeps nested-only channels on dm.allowFrom", () => { + const entry = { dmPolicy: "open", allowFrom: ["matrix:@owner"] }; + const changes: string[] = []; + + ensureOpenDmPolicyAllowFromWildcard({ + entry, + mode: "nestedOnly", + pathPrefix: "channels.matrix", + changes, + }); + + expect(entry).toEqual({ dm: { policy: "open", allowFrom: ["matrix:@owner", "*"] } }); + expect(changes).toEqual([ + '- channels.matrix.dm.policy: set to "open" (migrated from channels.matrix.dmPolicy)', + "- channels.matrix.allowFrom: removed after moving allowlist to channels.matrix.dm.allowFrom", + '- channels.matrix.dm.allowFrom: added "*" (required by dmPolicy="open")', + ]); + }); + + it("migrates top-canonical legacy dm aliases", () => { + const changes: string[] = []; + const result = normalizeLegacyDmAliases({ + entry: { dm: { policy: "allowlist", allowFrom: ["U1"] } }, + pathPrefix: "channels.slack", + changes, + }); + + expect(result.entry).toEqual({ dmPolicy: "allowlist", allowFrom: ["U1"] }); + expect(changes).toEqual([ + "Moved channels.slack.dm.policy → channels.slack.dmPolicy.", + "Moved channels.slack.dm.allowFrom → channels.slack.allowFrom.", + "Removed empty channels.slack.dm after migration.", + ]); + }); +}); + describe("config write helpers", () => { it("matches account ids case-insensitively", () => { expect( diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 96b83381630..e71b6fa0119 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -18,6 +18,20 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + ensureOpenDmPolicyAllowFromWildcard, + normalizeChannelDmPolicy, + normalizeLegacyDmAliases, + resolveChannelDmAccess, + resolveChannelDmAllowFrom, + resolveChannelDmPolicy, + setCanonicalDmAllowFrom, + type ChannelDmAccess, + type ChannelDmAllowFromMode, + type ChannelDmPolicy, + type DmAccessRecord, +} from "../channels/plugins/dm-access.js"; + const INTERNAL_MESSAGE_CHANNEL = "webchat"; export type ConfigWriteScope = ConfigWriteScopeLike; @@ -594,6 +608,14 @@ export function createScopedDmSecurityResolver< channelKey: string; resolvePolicy: (account: ResolvedAccount) => string | null | undefined; resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveAccess?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + account: ResolvedAccount; + }) => { + dmPolicy?: string | null; + allowFrom?: Array | null; + }; resolveFallbackAccountId?: (account: ResolvedAccount) => string | null | undefined; defaultPolicy?: string; allowFromPathSuffix?: string; @@ -611,14 +633,15 @@ export function createScopedDmSecurityResolver< cfg: OpenClawConfig; accountId?: string | null; account: ResolvedAccount; - }) => - buildAccountScopedDmSecurityPolicy({ + }) => { + const access = params.resolveAccess?.({ cfg, accountId, account }); + return buildAccountScopedDmSecurityPolicy({ cfg, channelKey: params.channelKey, accountId, fallbackAccountId: params.resolveFallbackAccountId?.(account) ?? account.accountId, - policy: params.resolvePolicy(account), - allowFrom: params.resolveAllowFrom(account) ?? [], + policy: access?.dmPolicy ?? params.resolvePolicy(account), + allowFrom: access?.allowFrom ?? params.resolveAllowFrom(account) ?? [], defaultPolicy: params.defaultPolicy, allowFromPathSuffix: params.allowFromPathSuffix, policyPathSuffix: params.policyPathSuffix, @@ -627,6 +650,7 @@ export function createScopedDmSecurityResolver< normalizeEntry: params.normalizeEntry, inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount, }); + }; } export { buildAccountScopedDmSecurityPolicy };