feat(discord): allow DM access groups from channel audiences

This commit is contained in:
Peter Steinberger
2026-05-01 22:00:55 +01:00
parent 536e4f49bc
commit b217cd0972
18 changed files with 534 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
9a22f3ea41864de9cc754d7237a5e1403582f21eeec421463e3faa80a243e7de config-baseline.json
7fa5e58fa863a13f2b9e06ea0fc536304f21e4e8929068f96f93968de91d1aae config-baseline.core.json
e14ddc6b9859128d4c5561cf80f322b7b24e0f87dac5bff170afbf2d6a9c3711 config-baseline.json
2b1eac57f1b08b461e4cb9931a766f472c668e18aedd78e2af89541d8b476933 config-baseline.core.json
c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json
7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json

View File

@@ -449,6 +449,58 @@ Example:
</Tab>
<Tab title="DM access groups">
Discord DMs can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
A Discord text channel has no separate member list. `type: "discord.channelAudience"` models membership as: the DM sender is a member of the configured guild and currently has effective `ViewChannel` permission on the configured channel after role and channel overwrites are applied.
Example: allow anyone who can see `#maintainers` to DM the bot, while keeping DMs closed to everyone else.
```json5
{
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "1456350064065904867",
channelId: "1456744319972282449",
membership: "canViewChannel",
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:maintainers"],
},
},
}
```
You can mix dynamic and static entries:
```json5
{
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "1456350064065904867",
channelId: "1456744319972282449",
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:maintainers", "discord:123456789012345678"],
},
},
}
```
Lookups fail closed. If Discord returns `Missing Access`, the member lookup fails, or the channel belongs to a different guild, the DM sender is treated as unauthorized.
Enable the Discord Developer Portal **Server Members Intent** for the bot when using channel-audience access groups. DMs do not include guild member state, so OpenClaw resolves the member through Discord REST at authorization time.
</Tab>
<Tab title="Guild policy">
Guild handling is controlled by `channels.discord.groupPolicy`:

View File

@@ -0,0 +1,70 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RequestClient } from "../internal/discord.js";
import { canViewDiscordGuildChannel } from "../send.permissions.js";
export const DISCORD_ACCESS_GROUP_PREFIX = "accessGroup:";
export function parseDiscordAccessGroupEntry(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed.startsWith(DISCORD_ACCESS_GROUP_PREFIX)) {
return null;
}
const name = trimmed.slice(DISCORD_ACCESS_GROUP_PREFIX.length).trim();
return name.length > 0 ? name : null;
}
export async function resolveDiscordDmAccessGroupEntries(params: {
cfg?: OpenClawConfig;
allowFrom: string[];
sender: { id: string };
accountId: string;
token?: string;
rest?: RequestClient;
}): Promise<string[]> {
const names = Array.from(
new Set(
params.allowFrom
.map((entry) => parseDiscordAccessGroupEntry(entry))
.filter((entry): entry is string => entry != null),
),
);
if (names.length === 0 || !params.cfg?.accessGroups) {
return [];
}
const matched: string[] = [];
for (const name of names) {
const group = params.cfg.accessGroups[name];
if (!group) {
continue;
}
if (group.type !== "discord.channelAudience") {
continue;
}
const membership = group.membership ?? "canViewChannel";
if (membership !== "canViewChannel") {
continue;
}
const allowed = await canViewDiscordGuildChannel(
group.guildId,
group.channelId,
params.sender.id,
{
cfg: params.cfg,
accountId: params.accountId,
token: params.token,
rest: params.rest,
},
).catch((err) => {
logVerbose(
`discord: accessGroup:${name} lookup failed for user ${params.sender.id}: ${String(err)}`,
);
return false;
});
if (allowed) {
matched.push(`${DISCORD_ACCESS_GROUP_PREFIX}${name}`);
}
}
return matched;
}

View File

@@ -1,6 +1,7 @@
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js";
import {
resolveComponentInteractionContext,
resolveDiscordChannelContext,
@@ -45,6 +46,22 @@ async function ensureDmComponentAuthorized(params: {
})
: { allowed: false };
};
const resolveAllowMatchWithAccessGroups = async (entries: string[]) => {
const staticMatch = resolveAllowMatch(entries);
if (staticMatch.allowed) {
return staticMatch;
}
const matchedGroups = await resolveDiscordDmAccessGroupEntries({
cfg: ctx.cfg,
allowFrom: entries,
sender: { id: user.id },
accountId: ctx.accountId,
token: ctx.token,
});
return matchedGroups.length > 0
? resolveAllowMatch([...entries, `discord:${user.id}`])
: staticMatch;
};
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
@@ -52,7 +69,7 @@ async function ensureDmComponentAuthorized(params: {
return false;
}
if (dmPolicy === "allowlist") {
const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []);
const allowMatch = await resolveAllowMatchWithAccessGroups(ctx.allowFrom ?? []);
if (allowMatch.allowed) {
return true;
}
@@ -73,7 +90,10 @@ async function ensureDmComponentAuthorized(params: {
dmPolicy,
});
const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
if (allowMatch.allowed) {
const dynamicAllowMatch = allowMatch.allowed
? allowMatch
: await resolveAllowMatchWithAccessGroups([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
if (dynamicAllowMatch.allowed) {
return true;
}

View File

@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
const canViewDiscordGuildChannelMock = vi.hoisted(() => vi.fn());
vi.mock("../send.permissions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.permissions.js")>();
return {
...actual,
canViewDiscordGuildChannel: canViewDiscordGuildChannelMock,
};
});
describe("resolveDiscordDmCommandAccess", () => {
const sender = {
id: "123",
@@ -8,6 +18,10 @@ describe("resolveDiscordDmCommandAccess", () => {
tag: "alice#0001",
};
beforeEach(() => {
canViewDiscordGuildChannelMock.mockReset();
});
async function resolveOpenDmAccess(configuredAllowFrom: string[]) {
return await resolveDiscordDmCommandAccess({
accountId: "default",
@@ -80,6 +94,65 @@ describe("resolveDiscordDmCommandAccess", () => {
expect(result.commandAuthorized).toBe(true);
});
it("authorizes allowlist DMs from a Discord channel audience access group", async () => {
canViewDiscordGuildChannelMock.mockResolvedValueOnce(true);
const result = await resolveDiscordDmCommandAccess({
accountId: "default",
dmPolicy: "allowlist",
configuredAllowFrom: ["accessGroup:maintainers"],
sender,
allowNameMatching: false,
useAccessGroups: true,
cfg: {
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "guild-1",
channelId: "channel-1",
},
},
},
token: "token",
readStoreAllowFrom: async () => [],
});
expect(canViewDiscordGuildChannelMock).toHaveBeenCalledWith(
"guild-1",
"channel-1",
"123",
expect.objectContaining({ accountId: "default", token: "token" }),
);
expect(result.decision).toBe("allow");
expect(result.commandAuthorized).toBe(true);
});
it("fails closed when a Discord channel audience access group lookup rejects", async () => {
canViewDiscordGuildChannelMock.mockRejectedValueOnce(new Error("missing intent"));
const result = await resolveDiscordDmCommandAccess({
accountId: "default",
dmPolicy: "allowlist",
configuredAllowFrom: ["accessGroup:maintainers"],
sender,
allowNameMatching: false,
useAccessGroups: true,
cfg: {
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "guild-1",
channelId: "channel-1",
},
},
},
readStoreAllowFrom: async () => [],
});
expect(result.decision).toBe("block");
expect(result.commandAuthorized).toBe(false);
});
it("keeps open DM blocked without wildcard even when access groups are disabled", async () => {
const result = await resolveDiscordDmCommandAccess({
accountId: "default",

View File

@@ -1,9 +1,12 @@
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
type DmGroupAccessDecision,
} from "openclaw/plugin-sdk/security-runtime";
import type { RequestClient } from "../internal/discord.js";
import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js";
import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js";
const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"];
@@ -39,6 +42,21 @@ function resolveDmPolicyCommandAuthorization(params: {
return params.commandAuthorized;
}
async function expandAllowFromWithDiscordAccessGroups(params: {
cfg?: OpenClawConfig;
allowFrom: string[];
sender: { id: string };
accountId: string;
token?: string;
rest?: RequestClient;
}) {
const matchedGroups = await resolveDiscordDmAccessGroupEntries(params);
if (matchedGroups.length === 0) {
return params.allowFrom;
}
return [...params.allowFrom, `discord:${params.sender.id}`];
}
export async function resolveDiscordDmCommandAccess(params: {
accountId: string;
dmPolicy: DiscordDmPolicy;
@@ -46,6 +64,9 @@ export async function resolveDiscordDmCommandAccess(params: {
sender: { id: string; name?: string; tag?: string };
allowNameMatching: boolean;
useAccessGroups: boolean;
cfg?: OpenClawConfig;
token?: string;
rest?: RequestClient;
readStoreAllowFrom?: () => Promise<string[]>;
}): Promise<DiscordDmCommandAccess> {
const storeAllowFrom = params.readStoreAllowFrom
@@ -58,13 +79,31 @@ export async function resolveDiscordDmCommandAccess(params: {
dmPolicy: params.dmPolicy,
shouldRead: params.dmPolicy !== "open",
});
const [configuredAllowFrom, effectiveStoreAllowFrom] = await Promise.all([
expandAllowFromWithDiscordAccessGroups({
cfg: params.cfg,
allowFrom: params.configuredAllowFrom,
sender: params.sender,
accountId: params.accountId,
token: params.token,
rest: params.rest,
}),
expandAllowFromWithDiscordAccessGroups({
cfg: params.cfg,
allowFrom: storeAllowFrom,
sender: params.sender,
accountId: params.accountId,
token: params.token,
rest: params.rest,
}),
]);
const access = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: params.dmPolicy,
allowFrom: params.configuredAllowFrom,
allowFrom: configuredAllowFrom,
groupAllowFrom: [],
storeAllowFrom,
storeAllowFrom: effectiveStoreAllowFrom,
isSenderAllowed: (allowEntries) =>
resolveSenderAllowMatch({
allowEntries,

View File

@@ -62,6 +62,9 @@ export async function resolveDiscordDmPreflightAccess(params: {
},
allowNameMatching: params.allowNameMatching,
useAccessGroups: params.useAccessGroups,
cfg: params.preflight.cfg,
token: params.preflight.token,
rest: params.preflight.client.rest,
});
const commandAuthorized = dmAccess.commandAuthorized || directBindingRecord != null;
if (dmAccess.decision === "allow") {

View File

@@ -271,6 +271,8 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: {
},
allowNameMatching,
useAccessGroups,
cfg,
rest: interaction.client.rest,
});
if (dmAccess.decision !== "allow") {
return false;

View File

@@ -384,6 +384,8 @@ async function dispatchDiscordCommandInteraction(params: {
},
allowNameMatching,
useAccessGroups,
cfg,
rest: interaction.client.rest,
});
commandAuthorized = dmAccess.commandAuthorized;
if (dmAccess.decision !== "allow") {

View File

@@ -1,4 +1,4 @@
import type { APIRole } from "discord-api-types/v10";
import type { APIChannel, APIGuild, APIGuildMember, APIRole } from "discord-api-types/v10";
import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10";
import { resolveDiscordRest } from "./client.js";
import {
@@ -60,6 +60,67 @@ async function fetchBotUserId(rest: RequestClient) {
return me.id;
}
function resolveMemberGuildPermissionBits(params: {
guild: Pick<APIGuild, "id" | "roles">;
member: Pick<APIGuildMember, "roles">;
}) {
const rolesById = new Map<string, APIRole>(
(params.guild.roles ?? []).map((role) => [role.id, role]),
);
const everyoneRole = rolesById.get(params.guild.id);
let permissions = 0n;
if (everyoneRole?.permissions) {
permissions = addPermissionBits(permissions, everyoneRole.permissions);
}
for (const roleId of params.member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
permissions = addPermissionBits(permissions, role.permissions);
}
}
return permissions;
}
function resolveMemberChannelPermissionBits(params: {
guildId: string;
userId: string;
guild: Pick<APIGuild, "id" | "roles">;
member: Pick<APIGuildMember, "roles">;
channel: APIChannel;
}) {
let permissions = resolveMemberGuildPermissionBits({
guild: params.guild,
member: params.member,
});
if (hasAdministrator(permissions)) {
return ALL_PERMISSIONS;
}
const overwrites =
"permission_overwrites" in params.channel ? (params.channel.permission_overwrites ?? []) : [];
for (const overwrite of overwrites) {
if (overwrite.id === params.guildId) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (params.member.roles?.includes(overwrite.id)) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (overwrite.id === params.userId) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
return permissions;
}
/**
* Fetch guild-level permissions for a user. This does not include channel-specific overwrites.
*/
@@ -74,25 +135,43 @@ export async function fetchMemberGuildPermissionsDiscord(
getGuild(rest, guildId),
getGuildMember(rest, guildId, userId),
]);
const rolesById = new Map<string, APIRole>((guild.roles ?? []).map((role) => [role.id, role]));
const everyoneRole = rolesById.get(guildId);
let permissions = 0n;
if (everyoneRole?.permissions) {
permissions = addPermissionBits(permissions, everyoneRole.permissions);
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
permissions = addPermissionBits(permissions, role.permissions);
}
}
return permissions;
return resolveMemberGuildPermissionBits({ guild, member });
} catch {
// Not a guild member, guild not found, or API failure.
return null;
}
}
export async function canViewDiscordGuildChannel(
guildId: string,
channelId: string,
userId: string,
opts: DiscordReactOpts,
): Promise<boolean> {
const rest = resolveDiscordRest(opts);
try {
const channel = await getChannel(rest, channelId);
const channelGuildId = "guild_id" in channel ? channel.guild_id : undefined;
if (channelGuildId !== guildId) {
return false;
}
const [guild, member] = await Promise.all([
getGuild(rest, guildId),
getGuildMember(rest, guildId, userId),
]);
const permissions = resolveMemberChannelPermissionBits({
guildId,
userId,
guild,
member,
channel,
});
return hasPermissionBit(permissions, PermissionFlagsBits.ViewChannel);
} catch {
return false;
}
}
/**
* Returns true when the user has ADMINISTRATOR or required permission bits
* matching the provided predicate.
@@ -181,51 +260,13 @@ export async function fetchChannelPermissionsDiscord(
getGuildMember(rest, guildId, botId),
]);
const rolesById = new Map<string, APIRole>((guild.roles ?? []).map((role) => [role.id, role]));
const everyoneRole = rolesById.get(guildId);
let base = 0n;
if (everyoneRole?.permissions) {
base = addPermissionBits(base, everyoneRole.permissions);
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
base = addPermissionBits(base, role.permissions);
}
}
if (hasAdministrator(base)) {
return {
channelId,
guildId,
permissions: bitfieldToPermissions(ALL_PERMISSIONS),
raw: ALL_PERMISSIONS.toString(),
isDm: false,
channelType,
};
}
let permissions = base;
const overwrites =
"permission_overwrites" in channel ? (channel.permission_overwrites ?? []) : [];
for (const overwrite of overwrites) {
if (overwrite.id === guildId) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (member.roles?.includes(overwrite.id)) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
for (const overwrite of overwrites) {
if (overwrite.id === botId) {
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
}
}
const permissions = resolveMemberChannelPermissionBits({
guildId,
userId: botId,
guild,
member,
channel,
});
return {
channelId,

View File

@@ -6,6 +6,7 @@ vi.mock("openclaw/plugin-sdk/web-media", () => discordWebMediaMockFactory());
let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord;
let editMessageDiscord: typeof import("./send.js").editMessageDiscord;
let canViewDiscordGuildChannel: typeof import("./send.js").canViewDiscordGuildChannel;
let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord;
let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord;
let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord;
@@ -29,6 +30,7 @@ beforeAll(async () => {
({
deleteMessageDiscord,
editMessageDiscord,
canViewDiscordGuildChannel,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
pinMessageDiscord,
@@ -695,6 +697,60 @@ describe("fetchChannelPermissionsDiscord", () => {
expect(res.permissions).toContain("Administrator");
expect(res.permissions).toContain("ViewChannel");
});
it("checks whether an arbitrary member can view a guild channel", async () => {
const { rest, getMock } = makeDiscordRest();
getMock
.mockResolvedValueOnce({
id: "chan1",
guild_id: "guild1",
permission_overwrites: [
{
id: "guild1",
deny: PermissionFlagsBits.ViewChannel.toString(),
allow: "0",
},
{
id: "role2",
deny: "0",
allow: PermissionFlagsBits.ViewChannel.toString(),
},
],
})
.mockResolvedValueOnce({
id: "guild1",
roles: [
{ id: "guild1", permissions: "0" },
{ id: "role2", permissions: "0" },
],
})
.mockResolvedValueOnce({ roles: ["role2"] });
await expect(
canViewDiscordGuildChannel("guild1", "chan1", "user1", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
}),
).resolves.toBe(true);
});
it("fails closed when the channel belongs to a different guild", async () => {
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({
id: "chan1",
guild_id: "guild2",
permission_overwrites: [],
});
await expect(
canViewDiscordGuildChannel("guild1", "chan1", "user1", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
}),
).resolves.toBe(false);
});
});
describe("readMessagesDiscord", () => {

View File

@@ -43,6 +43,7 @@ export { sendWebhookMessageDiscord } from "./send.webhook.js";
export { sendVoiceMessageDiscord } from "./send.voice.js";
export { sendTypingDiscord } from "./send.typing.js";
export {
canViewDiscordGuildChannel,
fetchChannelPermissionsDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,

View File

@@ -52,6 +52,44 @@ describe("$schema key in config (#14998)", () => {
});
});
describe("accessGroups config", () => {
it("accepts Discord channel audience access groups", () => {
const result = OpenClawSchema.safeParse({
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "1456350064065904867",
channelId: "1456744319972282449",
membership: "canViewChannel",
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:maintainers"],
},
},
});
expect(result.success).toBe(true);
});
it("rejects unknown access group membership modes", () => {
const result = OpenClawSchema.safeParse({
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "guild",
channelId: "channel",
membership: "roleMember",
},
},
});
expect(result.success).toBe(false);
});
});
describe("plugins.slots.contextEngine", () => {
it("accepts a contextEngine slot id", () => {
const result = OpenClawSchema.safeParse({

View File

@@ -1279,6 +1279,40 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.",
},
accessGroups: {
type: "object",
propertyNames: {
type: "string",
minLength: 1,
},
additionalProperties: {
oneOf: [
{
type: "object",
properties: {
type: {
type: "string",
const: "discord.channelAudience",
},
guildId: {
type: "string",
minLength: 1,
},
channelId: {
type: "string",
minLength: 1,
},
membership: {
type: "string",
const: "canViewChannel",
},
},
required: ["type", "guildId", "channelId"],
additionalProperties: false,
},
],
},
},
acp: {
type: "object",
properties: {

View File

@@ -0,0 +1,17 @@
export type DiscordChannelAudienceAccessGroup = {
/**
* Discord dynamic audience backed by the users who can currently view a guild
* channel.
*/
type: "discord.channelAudience";
/** Guild ID that owns the channel. */
guildId: string;
/** Channel ID whose effective ViewChannel permission defines the audience. */
channelId: string;
/** Audience predicate. Defaults to canViewChannel. */
membership?: "canViewChannel";
};
export type AccessGroupConfig = DiscordChannelAudienceAccessGroup;
export type AccessGroupsConfig = Record<string, AccessGroupConfig>;

View File

@@ -2,6 +2,7 @@ import type {
SilentReplyPolicyShape,
SilentReplyRewriteShape,
} from "../shared/silent-reply-policy.js";
import type { AccessGroupsConfig } from "./types.access-groups.js";
import type { AcpConfig } from "./types.acp.js";
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { ApprovalsConfig } from "./types.approvals.js";
@@ -50,6 +51,7 @@ export type OpenClawConfig = {
lastTouchedAt?: string;
};
auth?: AuthConfig;
accessGroups?: AccessGroupsConfig;
acp?: AcpConfig;
env?: {
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */

View File

@@ -3,6 +3,7 @@
export * from "./types.agent-defaults.js";
export * from "./types.agents.js";
export * from "./types.acp.js";
export * from "./types.access-groups.js";
export * from "./types.approvals.js";
export * from "./types.auth.js";
export * from "./types.base.js";

View File

@@ -49,6 +49,22 @@ const NodeHostSchema = z
.strict()
.optional();
const AccessGroupsSchema = z
.record(
z.string().min(1),
z.discriminatedUnion("type", [
z
.object({
type: z.literal("discord.channelAudience"),
guildId: z.string().min(1),
channelId: z.string().min(1),
membership: z.literal("canViewChannel").optional(),
})
.strict(),
]),
)
.optional();
const MemoryQmdPathSchema = z
.object({
path: z.string(),
@@ -522,6 +538,7 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
accessGroups: AccessGroupsSchema,
acp: z
.object({
enabled: z.boolean().optional(),