refactor: centralize channel DM access resolution

This commit is contained in:
Peter Steinberger
2026-04-29 15:05:15 +01:00
parent 606c881d27
commit 8c68e7535f
46 changed files with 380 additions and 346 deletions

View File

@@ -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.

View File

@@ -390,11 +390,11 @@ Example:
<Tabs>
<Tab title="DM policy">
`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:<id>`

View File

@@ -464,17 +464,17 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
<Tabs>
<Tab title="DM policy">
`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 <code>`.
</Tab>

View File

@@ -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.
<Accordion title="What createChatChannelPlugin does for you">
Instead of implementing low-level adapter interfaces manually, you pass
declarative options and the builder composes them:

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | 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<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
}
export function createDiscordActionGate(params: {

View File

@@ -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");
});

View File

@@ -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<ResolvedDiscordAccount, DiscordProbe>
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,
}),

View File

@@ -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),

View File

@@ -300,7 +300,7 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
}
export const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
dmAllowFromMode: "topOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,

View File

@@ -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"),

View File

@@ -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);

View File

@@ -97,6 +97,7 @@ export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: string[];
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
allowFrom?: string[];
guildEntries?: Record<string, DiscordGuildEntryResolved>;
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<ResolvedDiscordAccount>({
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()

View File

@@ -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 }) =>

View File

@@ -45,7 +45,7 @@ async function loadDiscordDoctorModule(): Promise<DiscordDoctorModule> {
}
const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
dmAllowFromMode: "topOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | 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<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
}
export function resolveSlackAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;

View File

@@ -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,

View File

@@ -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<ResolvedSlackAccount>({
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<ResolvedSlackAccount> = {

View File

@@ -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");
});

View File

@@ -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<ResolvedSlackAccount, SlackProbe> = 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,
}),

View File

@@ -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);

View File

@@ -48,7 +48,7 @@ export const collectSlackMutableAllowlistWarnings =
});
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
dmAllowFromMode: "topOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,

View File

@@ -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;

View File

@@ -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<ResolvedSlackAccount>({
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()

View File

@@ -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: [

View File

@@ -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<ResolvedSlackAccount>({
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: {

View File

@@ -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<string, unknown>,
});
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<string, unknown> | undefined,
parent: channelConfig as Record<string, unknown>,
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<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
>;
}
| undefined
)?.accounts?.[accountId]?.allowFrom ??
(
resolveChannelDmAllowFrom({
account: (
cfg.channels?.[params.channel] as
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
accounts?: Record<string, Record<string, unknown>>;
}
| undefined
)?.allowFrom ??
(
cfg.channels?.[params.channel] as
| {
accounts?: Record<
string,
{ dm?: { allowFrom?: Array<string | number> } }
>;
}
| undefined
)?.accounts?.[accountId]?.dm?.allowFrom ??
(
cfg.channels?.[params.channel] as
| {
dm?: { allowFrom?: Array<string | number> };
}
| undefined
)?.dm?.allowFrom,
)?.accounts?.[accountId],
parent: cfg.channels?.[params.channel] as
| Record<string, unknown>
| undefined,
}),
),
}
: {}),

View File

@@ -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 () => {

View File

@@ -1 +1 @@
export type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly";
export type { ChannelDmAllowFromMode as AllowFromMode } from "../../../channels/plugins/dm-access.js";

View File

@@ -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<string, unknown>)
: {};
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<string, unknown>)
: undefined;
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | 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: {

View File

@@ -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", () => {

View File

@@ -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<string | number>) {
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<string, unknown>)
: 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<string | number> | undefined;
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | 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<string, Record<string, unknown>>;

View File

@@ -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<string, unknown>;
changed: boolean;
};
export { normalizeLegacyDmAliases };
export type { CompatMutationResult };
export type LegacyStreamingAliasOptions = {
resolvedMode: string;
@@ -44,96 +45,6 @@ function ensureNestedRecord(owner: Record<string, unknown>, 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<string, unknown>;
pathPrefix: string;
changes: string[];
promoteAllowFrom?: boolean;
}): CompatMutationResult {
let changed = false;
let updated: Record<string, unknown> = params.entry;
const rawDm = updated.dm;
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : 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<string, unknown>;

View File

@@ -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[];

View File

@@ -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<string | number>;

View File

@@ -360,12 +360,13 @@ function buildAccountAllowlistAdapter<ResolvedAccount>(params: {
resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null;
readConfig: (
account: ResolvedAccount,
context: { cfg: OpenClawConfig; accountId?: string | null },
) => Awaited<ReturnType<NonNullable<ChannelAllowlistAdapter["readConfig"]>>>;
}): Pick<ChannelAllowlistAdapter, "supportsScope" | "readConfig" | "applyConfigEdit"> {
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<ResolvedAccount>(params: {
channelId: ChannelId;
resolveAccount: AllowlistAccountResolver<ResolvedAccount>;
normalize: AllowlistNormalizer;
resolveDmAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveDmAllowFrom: (
account: ResolvedAccount,
context: { cfg: OpenClawConfig; accountId?: string | null },
) => Array<string | number> | null | undefined;
resolveGroupAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined;
resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined;
@@ -391,8 +395,8 @@ export function buildDmGroupAccountAllowlistAdapter<ResolvedAccount>(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<ResolvedAccount>(params: {
channelId: ChannelId;
resolveAccount: AllowlistAccountResolver<ResolvedAccount>;
normalize: AllowlistNormalizer;
resolveDmAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
resolveDmAllowFrom: (
account: ResolvedAccount,
context: { cfg: OpenClawConfig; accountId?: string | null },
) => Array<string | number> | null | undefined;
resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined;
resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined;
}): Pick<ChannelAllowlistAdapter, "supportsScope" | "readConfig" | "applyConfigEdit"> {
@@ -416,8 +423,8 @@ export function buildLegacyDmAccountAllowlistAdapter<ResolvedAccount>(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),
}),

View File

@@ -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(

View File

@@ -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<string | number> | null | undefined;
resolveAccess?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedAccount;
}) => {
dmPolicy?: string | null;
allowFrom?: Array<string | number> | 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 };