refactor: dedupe config write policy helpers

This commit is contained in:
Peter Steinberger
2026-04-06 17:55:38 +01:00
parent a5a7ea0e39
commit c1fa747f69
3 changed files with 248 additions and 282 deletions

View File

@@ -0,0 +1,203 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
type AccountConfigWithWrites = {
configWrites?: boolean;
};
type ChannelConfigWithAccounts = {
configWrites?: boolean;
accounts?: Record<string, AccountConfigWithWrites>;
};
export type ConfigWriteScopeLike<TChannelId extends string = string> = {
channelId?: TChannelId | null;
accountId?: string | null;
};
export type ConfigWriteTargetLike<TChannelId extends string = string> =
| { kind: "global" }
| { kind: "channel"; scope: { channelId: TChannelId } }
| { kind: "account"; scope: { channelId: TChannelId; accountId: string } }
| { kind: "ambiguous"; scopes: ConfigWriteScopeLike<TChannelId>[] };
export type ConfigWriteAuthorizationResultLike<TChannelId extends string = string> =
| { allowed: true }
| {
allowed: false;
reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
blockedScope?: {
kind: "origin" | "target";
scope: ConfigWriteScopeLike<TChannelId>;
};
};
function listConfigWriteTargetScopes<TChannelId extends string>(
target?: ConfigWriteTargetLike<TChannelId>,
): ConfigWriteScopeLike<TChannelId>[] {
if (!target || target.kind === "global") {
return [];
}
if (target.kind === "ambiguous") {
return target.scopes;
}
return [target.scope];
}
function resolveChannelConfig<TChannelId extends string>(
cfg: OpenClawConfig,
channelId?: TChannelId | null,
): ChannelConfigWithAccounts | undefined {
if (!channelId) {
return undefined;
}
return (cfg.channels as Record<string, ChannelConfigWithAccounts> | undefined)?.[channelId];
}
function resolveChannelAccountConfig(
channelConfig: ChannelConfigWithAccounts,
accountId?: string | null,
): AccountConfigWithWrites | undefined {
return resolveAccountEntry(channelConfig.accounts, normalizeAccountId(accountId));
}
export function resolveChannelConfigWritesShared<TChannelId extends string>(params: {
cfg: OpenClawConfig;
channelId?: TChannelId | null;
accountId?: string | null;
}): boolean {
const channelConfig = resolveChannelConfig(params.cfg, params.channelId);
if (!channelConfig) {
return true;
}
const accountConfig = resolveChannelAccountConfig(channelConfig, params.accountId);
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
}
export function authorizeConfigWriteShared<TChannelId extends string>(params: {
cfg: OpenClawConfig;
origin?: ConfigWriteScopeLike<TChannelId>;
target?: ConfigWriteTargetLike<TChannelId>;
allowBypass?: boolean;
}): ConfigWriteAuthorizationResultLike<TChannelId> {
if (params.allowBypass) {
return { allowed: true };
}
if (params.target?.kind === "ambiguous") {
return { allowed: false, reason: "ambiguous-target" };
}
if (
params.origin?.channelId &&
!resolveChannelConfigWritesShared({
cfg: params.cfg,
channelId: params.origin.channelId,
accountId: params.origin.accountId,
})
) {
return {
allowed: false,
reason: "origin-disabled",
blockedScope: { kind: "origin", scope: params.origin },
};
}
const seen = new Set<string>();
for (const target of listConfigWriteTargetScopes(params.target)) {
if (!target.channelId) {
continue;
}
const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
if (
!resolveChannelConfigWritesShared({
cfg: params.cfg,
channelId: target.channelId,
accountId: target.accountId,
})
) {
return {
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: target },
};
}
}
return { allowed: true };
}
export function resolveExplicitConfigWriteTargetShared<TChannelId extends string>(
scope: ConfigWriteScopeLike<TChannelId>,
): ConfigWriteTargetLike<TChannelId> {
if (!scope.channelId) {
return { kind: "global" };
}
const accountId = normalizeAccountId(scope.accountId);
if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
return { kind: "channel", scope: { channelId: scope.channelId } };
}
return { kind: "account", scope: { channelId: scope.channelId, accountId } };
}
export function resolveConfigWriteTargetFromPathShared<TChannelId extends string>(params: {
path: string[];
normalizeChannelId: (raw: string) => TChannelId | null | undefined;
}): ConfigWriteTargetLike<TChannelId> {
if (params.path[0] !== "channels") {
return { kind: "global" };
}
if (params.path.length < 2) {
return { kind: "ambiguous", scopes: [] };
}
const channelId = params.normalizeChannelId(params.path[1] ?? "");
if (!channelId) {
return { kind: "ambiguous", scopes: [] };
}
if (params.path.length === 2) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
if (params.path[2] !== "accounts") {
return { kind: "channel", scope: { channelId } };
}
if (params.path.length < 4) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
return resolveExplicitConfigWriteTargetShared({
channelId,
accountId: normalizeAccountId(params.path[3]),
});
}
export function canBypassConfigWritePolicyShared(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
isInternalMessageChannel: (channel?: string | null) => boolean;
}): boolean {
return (
params.isInternalMessageChannel(params.channel) &&
params.gatewayClientScopes?.includes("operator.admin") === true
);
}
export function formatConfigWriteDeniedMessageShared<TChannelId extends string>(params: {
result: Exclude<ConfigWriteAuthorizationResultLike<TChannelId>, { allowed: true }>;
fallbackChannelId?: TChannelId | null;
}): string {
if (params.result.reason === "ambiguous-target") {
return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
}
const blocked = params.result.blockedScope?.scope;
const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
const hint = blocked?.channelId
? blocked.accountId
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
: `channels.${blocked.channelId}.configWrites=true`
: params.fallbackChannelId
? `channels.${params.fallbackChannelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
}

View File

@@ -1,49 +1,27 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import {
authorizeConfigWriteShared,
canBypassConfigWritePolicyShared,
formatConfigWriteDeniedMessageShared,
resolveChannelConfigWritesShared,
resolveConfigWriteTargetFromPathShared,
resolveExplicitConfigWriteTargetShared,
type ConfigWriteAuthorizationResultLike,
type ConfigWriteScopeLike,
type ConfigWriteTargetLike,
} from "./config-write-policy-shared.js";
import type { ChannelId } from "./types.js";
type AccountConfigWithWrites = {
configWrites?: boolean;
};
type ChannelConfigWithAccounts = {
configWrites?: boolean;
accounts?: Record<string, AccountConfigWithWrites>;
};
export type ConfigWriteScope = {
channelId?: ChannelId | null;
accountId?: string | null;
};
export type ConfigWriteTarget =
| { kind: "global" }
| { kind: "channel"; scope: { channelId: ChannelId } }
| { kind: "account"; scope: { channelId: ChannelId; accountId: string } }
| { kind: "ambiguous"; scopes: ConfigWriteScope[] };
export type ConfigWriteAuthorizationResult =
| { allowed: true }
| {
allowed: false;
reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
blockedScope?: { kind: "origin" | "target"; scope: ConfigWriteScope };
};
export type ConfigWriteScope = ConfigWriteScopeLike<ChannelId>;
export type ConfigWriteTarget = ConfigWriteTargetLike<ChannelId>;
export type ConfigWriteAuthorizationResult = ConfigWriteAuthorizationResultLike<ChannelId>;
export function resolveChannelConfigWrites(params: {
cfg: OpenClawConfig;
channelId?: ChannelId | null;
accountId?: string | null;
}): boolean {
const channelConfig = resolveChannelConfig(params.cfg, params.channelId);
if (!channelConfig) {
return true;
}
const accountConfig = resolveChannelAccountConfig(channelConfig, params.accountId);
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
return resolveChannelConfigWritesShared(params);
}
export function authorizeConfigWrite(params: {
@@ -52,87 +30,17 @@ export function authorizeConfigWrite(params: {
target?: ConfigWriteTarget;
allowBypass?: boolean;
}): ConfigWriteAuthorizationResult {
if (params.allowBypass) {
return { allowed: true };
}
if (params.target?.kind === "ambiguous") {
return { allowed: false, reason: "ambiguous-target" };
}
if (
params.origin?.channelId &&
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: params.origin.channelId,
accountId: params.origin.accountId,
})
) {
return {
allowed: false,
reason: "origin-disabled",
blockedScope: { kind: "origin", scope: params.origin },
};
}
const seen = new Set<string>();
for (const target of listConfigWriteTargetScopes(params.target)) {
if (!target.channelId) {
continue;
}
const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
if (
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: target.channelId,
accountId: target.accountId,
})
) {
return {
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: target },
};
}
}
return { allowed: true };
return authorizeConfigWriteShared(params);
}
export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget {
if (!scope.channelId) {
return { kind: "global" };
}
const accountId = normalizeAccountId(scope.accountId);
if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
return { kind: "channel", scope: { channelId: scope.channelId } };
}
return { kind: "account", scope: { channelId: scope.channelId, accountId } };
return resolveExplicitConfigWriteTargetShared(scope);
}
export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget {
if (path[0] !== "channels") {
return { kind: "global" };
}
if (path.length < 2) {
return { kind: "ambiguous", scopes: [] };
}
const channelId = path[1].trim().toLowerCase() as ChannelId;
if (!channelId) {
return { kind: "ambiguous", scopes: [] };
}
if (path.length === 2) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
if (path[2] !== "accounts") {
return { kind: "channel", scope: { channelId } };
}
if (path.length < 4) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
return resolveExplicitConfigWriteTarget({
channelId,
accountId: normalizeAccountId(path[3]),
return resolveConfigWriteTargetFromPathShared({
path,
normalizeChannelId: (raw) => raw.trim().toLowerCase() as ChannelId,
});
}
@@ -140,55 +48,15 @@ export function canBypassConfigWritePolicy(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
}): boolean {
return (
isInternalMessageChannel(params.channel) &&
params.gatewayClientScopes?.includes("operator.admin") === true
);
return canBypassConfigWritePolicyShared({
...params,
isInternalMessageChannel,
});
}
export function formatConfigWriteDeniedMessage(params: {
result: Exclude<ConfigWriteAuthorizationResult, { allowed: true }>;
fallbackChannelId?: ChannelId | null;
}): string {
if (params.result.reason === "ambiguous-target") {
return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
}
const blocked = params.result.blockedScope?.scope;
const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
const hint = blocked?.channelId
? blocked.accountId
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
: `channels.${blocked.channelId}.configWrites=true`
: params.fallbackChannelId
? `channels.${params.fallbackChannelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
}
function listConfigWriteTargetScopes(target?: ConfigWriteTarget): ConfigWriteScope[] {
if (!target || target.kind === "global") {
return [];
}
if (target.kind === "ambiguous") {
return target.scopes;
}
return [target.scope];
}
function resolveChannelConfig(
cfg: OpenClawConfig,
channelId?: ChannelId | null,
): ChannelConfigWithAccounts | undefined {
if (!channelId) {
return undefined;
}
return (cfg.channels as Record<string, ChannelConfigWithAccounts> | undefined)?.[channelId];
}
function resolveChannelAccountConfig(
channelConfig: ChannelConfigWithAccounts,
accountId?: string | null,
): AccountConfigWithWrites | undefined {
return resolveAccountEntry(channelConfig.accounts, normalizeAccountId(accountId));
return formatConfigWriteDeniedMessageShared(params);
}

View File

@@ -2,42 +2,26 @@ import {
deleteAccountFromConfigSection as deleteAccountFromConfigSectionInSection,
setAccountEnabledInConfigSection as setAccountEnabledInConfigSectionInSection,
} from "../channels/plugins/config-helpers.js";
import {
authorizeConfigWriteShared,
canBypassConfigWritePolicyShared,
formatConfigWriteDeniedMessageShared,
resolveChannelConfigWritesShared,
type ConfigWriteAuthorizationResultLike,
type ConfigWriteScopeLike,
type ConfigWriteTargetLike,
} from "../channels/plugins/config-write-policy-shared.js";
import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
const INTERNAL_MESSAGE_CHANNEL = "webchat";
type AccountConfigWithWrites = {
configWrites?: boolean;
};
type ChannelConfigWithAccounts = {
configWrites?: boolean;
accounts?: Record<string, AccountConfigWithWrites>;
};
export type ConfigWriteScope = {
channelId?: string | null;
accountId?: string | null;
};
export type ConfigWriteTarget =
| { kind: "global" }
| { kind: "channel"; scope: { channelId: string } }
| { kind: "account"; scope: { channelId: string; accountId: string } }
| { kind: "ambiguous"; scopes: ConfigWriteScope[] };
export type ConfigWriteAuthorizationResult =
| { allowed: true }
| {
allowed: false;
reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
blockedScope?: { kind: "origin" | "target"; scope: ConfigWriteScope };
};
export type ConfigWriteScope = ConfigWriteScopeLike;
export type ConfigWriteTarget = ConfigWriteTargetLike;
export type ConfigWriteAuthorizationResult = ConfigWriteAuthorizationResultLike;
type ChannelCrudConfigAdapter<ResolvedAccount> = Pick<
ChannelConfigAdapter<ResolvedAccount>,
@@ -105,45 +89,12 @@ function buildAccountScopedDmSecurityPolicy(params: {
};
}
function resolveChannelConfig(
cfg: OpenClawConfig,
channelId?: string | null,
): ChannelConfigWithAccounts | undefined {
if (!channelId) {
return undefined;
}
return (cfg.channels as Record<string, ChannelConfigWithAccounts> | undefined)?.[channelId];
}
function resolveChannelAccountConfig(
channelConfig: ChannelConfigWithAccounts,
accountId?: string | null,
): AccountConfigWithWrites | undefined {
return resolveAccountEntry(channelConfig.accounts, normalizeAccountId(accountId));
}
function listConfigWriteTargetScopes(target?: ConfigWriteTarget): ConfigWriteScope[] {
if (!target || target.kind === "global") {
return [];
}
if (target.kind === "ambiguous") {
return target.scopes;
}
return [target.scope];
}
export function resolveChannelConfigWrites(params: {
cfg: OpenClawConfig;
channelId?: string | null;
accountId?: string | null;
}): boolean {
const channelConfig = resolveChannelConfig(params.cfg, params.channelId);
if (!channelConfig) {
return true;
}
const accountConfig = resolveChannelAccountConfig(channelConfig, params.accountId);
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
return resolveChannelConfigWritesShared(params);
}
export function authorizeConfigWrite(params: {
@@ -152,81 +103,25 @@ export function authorizeConfigWrite(params: {
target?: ConfigWriteTarget;
allowBypass?: boolean;
}): ConfigWriteAuthorizationResult {
if (params.allowBypass) {
return { allowed: true };
}
if (params.target?.kind === "ambiguous") {
return { allowed: false, reason: "ambiguous-target" };
}
if (
params.origin?.channelId &&
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: params.origin.channelId,
accountId: params.origin.accountId,
})
) {
return {
allowed: false,
reason: "origin-disabled",
blockedScope: { kind: "origin", scope: params.origin },
};
}
const seen = new Set<string>();
for (const target of listConfigWriteTargetScopes(params.target)) {
if (!target.channelId) {
continue;
}
const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
if (
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: target.channelId,
accountId: target.accountId,
})
) {
return {
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: target },
};
}
}
return { allowed: true };
return authorizeConfigWriteShared(params);
}
export function canBypassConfigWritePolicy(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
}): boolean {
return (
params.channel?.trim().toLowerCase() === INTERNAL_MESSAGE_CHANNEL &&
params.gatewayClientScopes?.includes("operator.admin") === true
);
return canBypassConfigWritePolicyShared({
...params,
isInternalMessageChannel: (channel) =>
channel?.trim().toLowerCase() === INTERNAL_MESSAGE_CHANNEL,
});
}
export function formatConfigWriteDeniedMessage(params: {
result: Exclude<ConfigWriteAuthorizationResult, { allowed: true }>;
fallbackChannelId?: string | null;
}): string {
if (params.result.reason === "ambiguous-target") {
return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
}
const blocked = params.result.blockedScope?.scope;
const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
const hint = blocked?.channelId
? blocked.accountId
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
: `channels.${blocked.channelId}.configWrites=true`
: params.fallbackChannelId
? `channels.${params.fallbackChannelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
return formatConfigWriteDeniedMessageShared(params);
}
type ChannelConfigAccessorParams<Config extends OpenClawConfig = OpenClawConfig> = {