Files
openclaw/src/auto-reply/command-auth.ts
2026-04-06 07:41:08 +01:00

720 lines
21 KiB
TypeScript

import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isInternalMessageChannel,
normalizeMessageChannel,
} from "../utils/message-channel.js";
import type { MsgContext } from "./templating.js";
export type CommandAuthorization = {
providerId?: ChannelId;
ownerList: string[];
senderId?: string;
senderIsOwner: boolean;
isAuthorizedSender: boolean;
from?: string;
to?: string;
};
type InferredProviderCandidate = {
providerId: ChannelId;
hadResolutionError: boolean;
};
type InferredProviderProbe = {
candidates: InferredProviderCandidate[];
droppedResolutionError: boolean;
};
type ProviderAllowFromResolution = {
allowFrom: Array<string | number>;
allowFromList: string[];
hadResolutionError: boolean;
};
type OwnerAuthorizationState = {
allowAll: boolean;
ownerAllowAll: boolean;
ownerCandidatesForCommands: string[];
explicitOwners: string[];
ownerList: string[];
};
function resolveProviderFromContext(
ctx: MsgContext,
cfg: OpenClawConfig,
): { providerId: ChannelId | undefined; hadResolutionError: boolean } {
const explicitMessageChannels = [ctx.Surface, ctx.OriginatingChannel, ctx.Provider]
.map((value) => normalizeMessageChannel(value))
.filter((value): value is string => Boolean(value));
const explicitMessageChannel = explicitMessageChannels.find(
(value) => value !== INTERNAL_MESSAGE_CHANNEL,
);
if (!explicitMessageChannel && explicitMessageChannels.includes(INTERNAL_MESSAGE_CHANNEL)) {
return { providerId: undefined, hadResolutionError: false };
}
const direct =
normalizeAnyChannelId(explicitMessageChannel ?? undefined) ??
(explicitMessageChannel as ChannelId | undefined) ??
normalizeAnyChannelId(ctx.Provider) ??
normalizeAnyChannelId(ctx.Surface) ??
normalizeAnyChannelId(ctx.OriginatingChannel);
if (direct) {
return { providerId: direct, hadResolutionError: false };
}
const candidates = [ctx.From, ctx.To]
.filter((value): value is string => Boolean(value?.trim()))
.flatMap((value) => value.split(":").map((part) => part.trim()));
for (const candidate of candidates) {
const normalizedCandidateChannel = normalizeMessageChannel(candidate);
if (normalizedCandidateChannel === INTERNAL_MESSAGE_CHANNEL) {
return { providerId: undefined, hadResolutionError: false };
}
const normalized =
normalizeAnyChannelId(normalizedCandidateChannel ?? undefined) ??
(normalizedCandidateChannel as ChannelId | undefined) ??
normalizeAnyChannelId(candidate);
if (normalized) {
return { providerId: normalized, hadResolutionError: false };
}
}
const inferredProviders = probeInferredProviders(ctx, cfg);
const inferred = inferredProviders.candidates;
if (inferred.length === 1) {
return {
providerId: inferred[0].providerId,
hadResolutionError: inferred[0].hadResolutionError,
};
}
return {
providerId: undefined,
hadResolutionError:
inferredProviders.droppedResolutionError ||
inferred.some((entry) => entry.hadResolutionError),
};
}
function probeInferredProviders(ctx: MsgContext, cfg: OpenClawConfig): InferredProviderProbe {
let droppedResolutionError = false;
const candidates = listChannelPlugins()
.map((plugin) => {
const resolvedAllowFrom = buildProviderAllowFromResolution({
plugin,
cfg,
accountId: ctx.AccountId,
});
if (resolvedAllowFrom.allowFromList.length === 0) {
if (resolvedAllowFrom.hadResolutionError) {
droppedResolutionError = true;
}
return null;
}
return {
providerId: plugin.id,
hadResolutionError: resolvedAllowFrom.hadResolutionError,
};
})
.filter((value): value is InferredProviderCandidate => Boolean(value));
return {
candidates,
droppedResolutionError,
};
}
function formatAllowFromList(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}): string[] {
const { plugin, cfg, accountId, allowFrom } = params;
if (!allowFrom || allowFrom.length === 0) {
return [];
}
if (plugin?.config?.formatAllowFrom) {
return plugin.config.formatAllowFrom({ cfg, accountId, allowFrom });
}
return normalizeStringEntries(allowFrom);
}
function normalizeAllowFromEntry(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
value: string;
}): string[] {
const normalized = formatAllowFromList({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
allowFrom: [params.value],
});
return normalized.filter((entry) => entry.trim().length > 0);
}
function isWildcardAllowFromEntry(entry: string): boolean {
return entry.trim() === "*";
}
function hasWildcardAllowFrom(list: string[]): boolean {
return list.some((entry) => isWildcardAllowFromEntry(entry));
}
function stripWildcardAllowFrom(list: string[]): string[] {
return list.filter((entry) => !isWildcardAllowFromEntry(entry));
}
function resolveProviderAllowFrom(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
}): {
allowFrom: Array<string | number>;
hadResolutionError: boolean;
} {
const { plugin, cfg, accountId } = params;
const providerId = plugin?.id;
if (!plugin?.config?.resolveAllowFrom) {
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: false,
};
}
try {
const allowFrom = plugin.config.resolveAllowFrom({ cfg, accountId });
if (allowFrom == null) {
return {
allowFrom: [],
hadResolutionError: false,
};
}
if (!Array.isArray(allowFrom)) {
console.warn(
`[command-auth] resolveAllowFrom returned an invalid allowFrom for provider "${providerId}", falling back to config allowFrom: invalid_result`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
return {
allowFrom,
hadResolutionError: false,
};
} catch (err) {
console.warn(
`[command-auth] resolveAllowFrom threw for provider "${providerId}", falling back to config allowFrom: ${describeAllowFromResolutionError(err)}`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
}
function buildProviderAllowFromResolution(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
forceFallbackResolutionError?: boolean;
}): ProviderAllowFromResolution {
const providerId = params.providerId ?? params.plugin?.id;
const resolvedAllowFrom = params.forceFallbackResolutionError
? {
allowFrom: resolveFallbackAllowFrom({
cfg: params.cfg,
providerId,
accountId: params.accountId,
}),
hadResolutionError: true,
}
: resolveProviderAllowFrom({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
});
return {
...resolvedAllowFrom,
allowFromList: formatAllowFromList({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
allowFrom: resolvedAllowFrom.allowFrom,
}),
};
}
function describeAllowFromResolutionError(err: unknown): string {
if (err instanceof Error) {
const name = err.name.trim();
return name || "Error";
}
return "unknown_error";
}
function resolveOwnerAllowFromList(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
allowFrom?: Array<string | number>;
}): string[] {
const raw = params.allowFrom ?? params.cfg.commands?.ownerAllowFrom;
if (!Array.isArray(raw) || raw.length === 0) {
return [];
}
const filtered: string[] = [];
for (const entry of raw) {
const trimmed = String(entry ?? "").trim();
if (!trimmed) {
continue;
}
const separatorIndex = trimmed.indexOf(":");
if (separatorIndex > 0) {
const prefix = trimmed.slice(0, separatorIndex);
const channel = normalizeAnyChannelId(prefix);
if (channel) {
if (params.providerId && channel !== params.providerId) {
continue;
}
const remainder = trimmed.slice(separatorIndex + 1).trim();
if (remainder) {
filtered.push(remainder);
}
continue;
}
}
filtered.push(trimmed);
}
return formatAllowFromList({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
allowFrom: filtered,
});
}
/**
* Resolves the commands.allowFrom list for a given provider.
* Returns the provider-specific list if defined, otherwise the "*" global list.
* Returns null if commands.allowFrom is not configured at all (fall back to channel allowFrom).
*/
function resolveCommandsAllowFromList(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
}): string[] | null {
const { plugin, cfg, accountId, providerId } = params;
const commandsAllowFrom = cfg.commands?.allowFrom;
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
return null; // Not configured, fall back to channel allowFrom
}
// Check provider-specific list first, then fall back to global "*"
const providerKey = providerId ?? "";
const providerList = commandsAllowFrom[providerKey];
const globalList = commandsAllowFrom["*"];
const rawList = Array.isArray(providerList) ? providerList : globalList;
if (!Array.isArray(rawList)) {
return null; // No applicable list found
}
return formatAllowFromList({
plugin,
cfg,
accountId,
allowFrom: rawList,
});
}
function resolveOwnerCandidatesForCommands(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
to?: string;
allowAll: boolean;
allowFromList: string[];
}): string[] {
if (params.allowAll) {
return [];
}
const ownerCandidatesForCommands = stripWildcardAllowFrom(params.allowFromList);
if (ownerCandidatesForCommands.length > 0 || !params.to) {
return ownerCandidatesForCommands;
}
const normalizedTo = normalizeAllowFromEntry({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
value: params.to,
});
return normalizedTo.length > 0 ? [...ownerCandidatesForCommands, ...normalizedTo] : [];
}
function resolveOwnerAuthorizationState(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
to?: string;
allowFromList: string[];
hadResolutionError: boolean;
configOwnerAllowFrom?: Array<string | number>;
contextOwnerAllowFrom?: Array<string | number>;
}): OwnerAuthorizationState {
const configOwnerAllowFromList = resolveOwnerAllowFromList({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
providerId: params.providerId,
allowFrom: params.configOwnerAllowFrom,
});
const contextOwnerAllowFromList = resolveOwnerAllowFromList({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
providerId: params.providerId,
allowFrom: params.contextOwnerAllowFrom,
});
const allowAll =
!params.hadResolutionError &&
(params.allowFromList.length === 0 || hasWildcardAllowFrom(params.allowFromList));
const ownerCandidatesForCommands = resolveOwnerCandidatesForCommands({
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
allowAll,
allowFromList: params.allowFromList,
});
const ownerAllowAll = hasWildcardAllowFrom(configOwnerAllowFromList);
const explicitOwners = stripWildcardAllowFrom(configOwnerAllowFromList);
const explicitOverrides = stripWildcardAllowFrom(contextOwnerAllowFromList);
const ownerList = Array.from(
new Set(
explicitOwners.length > 0
? explicitOwners
: ownerAllowAll
? []
: explicitOverrides.length > 0
? explicitOverrides
: ownerCandidatesForCommands,
),
);
return {
allowAll,
ownerAllowAll,
ownerCandidatesForCommands,
explicitOwners,
ownerList,
};
}
function resolveCommandSenderAuthorization(params: {
commandAuthorized: boolean;
isOwnerForCommands: boolean;
senderCandidates: string[];
commandsAllowFromList: string[] | null;
providerResolutionError: boolean;
commandsAllowFromConfigured: boolean;
}): boolean {
if (
params.commandsAllowFromList !== null ||
(params.providerResolutionError && params.commandsAllowFromConfigured)
) {
const commandsAllowFromList = params.commandsAllowFromList;
const commandsAllowAll =
!params.providerResolutionError &&
Boolean(commandsAllowFromList && hasWildcardAllowFrom(commandsAllowFromList));
const matchedCommandsAllowFrom = commandsAllowFromList?.length
? params.senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate))
: undefined;
return (
!params.providerResolutionError && (commandsAllowAll || Boolean(matchedCommandsAllowFrom))
);
}
return params.commandAuthorized && params.isOwnerForCommands;
}
function isConversationLikeIdentity(value: string): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized.includes("@g.us")) {
return true;
}
if (normalized.startsWith("chat_id:")) {
return true;
}
return /(^|:)(channel|group|thread|topic|room|space|spaces):/.test(normalized);
}
function shouldUseFromAsSenderFallback(params: {
from?: string | null;
chatType?: string | null;
}): boolean {
const from = (params.from ?? "").trim();
if (!from) {
return false;
}
const chatType = (params.chatType ?? "").trim().toLowerCase();
if (chatType && chatType !== "direct") {
return false;
}
return !isConversationLikeIdentity(from);
}
function resolveSenderCandidates(params: {
plugin?: ChannelPlugin;
providerId?: ChannelId;
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
senderE164?: string | null;
from?: string | null;
chatType?: string | null;
}): string[] {
const { plugin, cfg, accountId } = params;
const candidates: string[] = [];
const pushCandidate = (value?: string | null) => {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return;
}
candidates.push(trimmed);
};
if (plugin?.commands?.preferSenderE164ForCommands) {
pushCandidate(params.senderE164);
pushCandidate(params.senderId);
} else {
pushCandidate(params.senderId);
pushCandidate(params.senderE164);
}
if (
candidates.length === 0 &&
shouldUseFromAsSenderFallback({ from: params.from, chatType: params.chatType })
) {
pushCandidate(params.from);
}
const normalized: string[] = [];
for (const sender of candidates) {
const entries = normalizeAllowFromEntry({ plugin, cfg, accountId, value: sender });
for (const entry of entries) {
if (!normalized.includes(entry)) {
normalized.push(entry);
}
}
}
return normalized;
}
function resolveFallbackAllowFrom(params: {
cfg: OpenClawConfig;
providerId?: ChannelId;
accountId?: string | null;
}): Array<string | number> {
const providerId = params.providerId?.trim();
if (!providerId) {
return [];
}
const channels = params.cfg.channels as
| Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
accounts?: Record<
string,
{
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
>;
}
| undefined
>
| undefined;
const channelCfg = channels?.[providerId];
const accountCfg =
resolveFallbackAccountConfig(channelCfg?.accounts, params.accountId) ??
resolveFallbackDefaultAccountConfig(channelCfg);
const allowFrom =
accountCfg?.allowFrom ??
accountCfg?.dm?.allowFrom ??
channelCfg?.allowFrom ??
channelCfg?.dm?.allowFrom;
return Array.isArray(allowFrom) ? allowFrom : [];
}
function resolveFallbackAccountConfig(
accounts:
| Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>
| undefined,
accountId?: string | null,
) {
const normalizedAccountId = accountId?.trim().toLowerCase();
if (!accounts || !normalizedAccountId) {
return undefined;
}
const direct = accounts[normalizedAccountId];
if (direct) {
return direct;
}
const matchKey = Object.keys(accounts).find(
(key) => key.trim().toLowerCase() === normalizedAccountId,
);
return matchKey ? accounts[matchKey] : undefined;
}
function resolveFallbackDefaultAccountConfig(
channelCfg:
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
defaultAccount?: string;
accounts?: Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>;
}
| undefined,
) {
const accounts = channelCfg?.accounts;
if (!accounts) {
return undefined;
}
const preferred =
resolveFallbackAccountConfig(accounts, channelCfg?.defaultAccount) ??
resolveFallbackAccountConfig(accounts, "default");
if (preferred) {
return preferred;
}
const definedAccounts = Object.values(accounts).filter(Boolean);
return definedAccounts.length === 1 ? definedAccounts[0] : undefined;
}
export function resolveCommandAuthorization(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
commandAuthorized: boolean;
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const { providerId, hadResolutionError: providerResolutionError } = resolveProviderFromContext(
ctx,
cfg,
);
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const from = (ctx.From ?? "").trim();
const to = (ctx.To ?? "").trim();
const commandsAllowFromConfigured = Boolean(
cfg.commands?.allowFrom && typeof cfg.commands.allowFrom === "object",
);
// Check if commands.allowFrom is configured (separate command authorization)
const commandsAllowFromList = resolveCommandsAllowFromList({
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
});
const resolvedAllowFrom = buildProviderAllowFromResolution({
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
forceFallbackResolutionError: providerResolutionError,
});
const ownerState = resolveOwnerAuthorizationState({
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
to,
allowFromList: resolvedAllowFrom.allowFromList,
hadResolutionError: resolvedAllowFrom.hadResolutionError,
configOwnerAllowFrom: cfg.commands?.ownerAllowFrom,
contextOwnerAllowFrom: ctx.OwnerAllowFrom,
});
const senderCandidates = resolveSenderCandidates({
plugin,
providerId,
cfg,
accountId: ctx.AccountId,
senderId: ctx.SenderId,
senderE164: ctx.SenderE164,
from,
chatType: ctx.ChatType,
});
const matchedSender = ownerState.ownerList.length
? senderCandidates.find((candidate) => ownerState.ownerList.includes(candidate))
: undefined;
const matchedCommandOwner = ownerState.ownerCandidatesForCommands.length
? senderCandidates.find((candidate) =>
ownerState.ownerCandidatesForCommands.includes(candidate),
)
: undefined;
const senderId = matchedSender ?? senderCandidates[0];
const enforceOwner = Boolean(plugin?.commands?.enforceOwnerForCommands);
const senderIsOwnerByIdentity = Boolean(matchedSender);
const senderIsOwnerByScope =
isInternalMessageChannel(ctx.Provider) &&
Array.isArray(ctx.GatewayClientScopes) &&
ctx.GatewayClientScopes.includes("operator.admin");
const ownerAllowlistConfigured = ownerState.ownerAllowAll || ownerState.explicitOwners.length > 0;
const senderIsOwner = ctx.ForceSenderIsOwnerFalse
? false
: senderIsOwnerByIdentity || senderIsOwnerByScope || ownerState.ownerAllowAll;
const requireOwner = enforceOwner || ownerAllowlistConfigured;
const isOwnerForCommands = !requireOwner
? true
: ownerState.ownerAllowAll
? true
: ownerAllowlistConfigured
? senderIsOwner
: ownerState.allowAll ||
ownerState.ownerCandidatesForCommands.length === 0 ||
Boolean(matchedCommandOwner);
const isAuthorizedSender = resolveCommandSenderAuthorization({
commandAuthorized,
isOwnerForCommands,
senderCandidates,
commandsAllowFromList,
providerResolutionError,
commandsAllowFromConfigured,
});
return {
providerId,
ownerList: ownerState.ownerList,
senderId: senderId || undefined,
senderIsOwner,
isAuthorizedSender,
from: from || undefined,
to: to || undefined,
};
}