diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bce2b5f4b46..e77ce9843eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,7 @@ jobs: pnpm test:extensions ;; contracts|contracts-protocol) + pnpm build pnpm test:contracts pnpm protocol:check ;; diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index 47fdc2647c4..e4ceb166ada 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,5 +1,5 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 83cd17dee25..d7df59874e3 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1,7 +1,7 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound"; import { createInboundDebouncer, diff --git a/package.json b/package.json index 43e22211f45..3233a7651b3 100644 --- a/package.json +++ b/package.json @@ -344,6 +344,10 @@ "types": "./dist/plugin-sdk/channel-config-helpers.d.ts", "default": "./dist/plugin-sdk/channel-config-helpers.js" }, + "./plugin-sdk/channel-config-writes": { + "types": "./dist/plugin-sdk/channel-config-writes.d.ts", + "default": "./dist/plugin-sdk/channel-config-writes.js" + }, "./plugin-sdk/channel-config-primitives": { "types": "./dist/plugin-sdk/channel-config-primitives.d.ts", "default": "./dist/plugin-sdk/channel-config-primitives.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a54ea0491d4..b3e5c174bcf 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -76,6 +76,7 @@ "discord-timeouts", "extension-shared", "channel-config-helpers", + "channel-config-writes", "channel-config-primitives", "channel-config-schema", "channel-actions", diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 7bc1e7ae9a6..44c3d29385c 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -12,7 +12,11 @@ import { createTopLevelChannelConfigBase, createHybridChannelConfigBase, mapAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, resolveOptionalConfigString, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, } from "./channel-config-helpers.js"; const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID; @@ -64,6 +68,51 @@ describe("resolveOptionalConfigString", () => { }); }); +describe("provider config readers", () => { + it("reads merged WhatsApp allowFrom/defaultTo without the channel registry", () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["root"], + defaultTo: " root:chat ", + accounts: { + alt: { + allowFrom: ["49123", "42"], + defaultTo: " alt:chat ", + }, + }, + }, + }, + }; + + expect(resolveWhatsAppConfigAllowFrom({ cfg, accountId: "alt" })).toEqual(["49123", "42"]); + expect(resolveWhatsAppConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat"); + }); + + it("reads merged iMessage allowFrom/defaultTo without the channel registry", () => { + const cfg = { + channels: { + imessage: { + allowFrom: ["root"], + defaultTo: " root:chat ", + accounts: { + alt: { + allowFrom: ["chat_id:9", "user@example.com"], + defaultTo: " alt:chat ", + }, + }, + }, + }, + }; + + expect(resolveIMessageConfigAllowFrom({ cfg, accountId: "alt" })).toEqual([ + "chat_id:9", + "user@example.com", + ]); + expect(resolveIMessageConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat"); + }); +}); + describe("adaptScopedAccountAccessor", () => { it("binds positional callback args into the shared account context object", () => { const accessor = adaptScopedAccountAccessor(({ cfg, accountId }) => ({ diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 3c5721d563d..1ab5a31aec3 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -1,42 +1,54 @@ +import { resolveMergedAccountConfig } from "../channels/plugins/account-helpers.js"; import { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, + deleteAccountFromConfigSection as deleteAccountFromConfigSectionInSection, + setAccountEnabledInConfigSection as setAccountEnabledInConfigSectionInSection, } from "../channels/plugins/config-helpers.js"; -import { - authorizeConfigWrite, - canBypassConfigWritePolicy, - formatConfigWriteDeniedMessage, - resolveChannelConfigWrites, - type ConfigWriteAuthorizationResult, - type ConfigWriteScope, - type ConfigWriteTarget, -} from "../channels/plugins/config-writes.js"; -import { - collectAllowlistProviderGroupPolicyWarnings, - collectAllowlistProviderRestrictSendersWarnings, - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "../channels/plugins/group-policy-warnings.js"; -import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; -import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; -import { getChannelPlugin } from "../channels/plugins/registry.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; -// `node --import tsx` can emit `__name(...)` wrappers for function expressions. -// Keep a local no-op helper so direct TS loads (used by config-surface tooling) stay stable. -const __name = (value: T): T => value; - -export { - authorizeConfigWrite, - canBypassConfigWritePolicy, - formatConfigWriteDeniedMessage, - resolveChannelConfigWrites, +type SimpleDirectMessageConfig = { + allowFrom?: Array; + defaultTo?: string | number | null; }; -export type { ConfigWriteAuthorizationResult, ConfigWriteScope, ConfigWriteTarget }; + +type SimpleScopedChannelConfig = SimpleDirectMessageConfig & { + accounts?: Record>; +}; + +const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; +const WHATSAPP_LID_RE = /^(\d+)@lid$/i; +const INTERNAL_MESSAGE_CHANNEL = "webchat"; + +type AccountConfigWithWrites = { + configWrites?: boolean; +}; + +type ChannelConfigWithAccounts = { + configWrites?: boolean; + accounts?: Record; +}; + +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 }; + }; type ChannelCrudConfigAdapter = Pick< ChannelConfigAdapter, @@ -61,6 +73,217 @@ type ChannelConfigAdapterWithAccessors = Pick< | "resolveDefaultTo" >; +function formatPairingApproveHint(channelId: string): string { + const listCmd = formatCliCommand(`openclaw pairing list ${channelId}`); + const approveCmd = formatCliCommand(`openclaw pairing approve ${channelId} `); + return `Approve via: ${listCmd} / ${approveCmd}`; +} + +function buildAccountScopedDmSecurityPolicy(params: { + cfg: OpenClawConfig; + channelKey: string; + accountId?: string | null; + fallbackAccountId?: string | null; + policy?: string | null; + allowFrom?: Array | null; + defaultPolicy?: string; + allowFromPathSuffix?: string; + policyPathSuffix?: string; + approveChannelId?: string; + approveHint?: string; + normalizeEntry?: (raw: string) => string; +}) { + const resolvedAccountId = params.accountId ?? params.fallbackAccountId ?? DEFAULT_ACCOUNT_ID; + const channelConfig = (params.cfg.channels as Record | undefined)?.[ + params.channelKey + ] as { accounts?: Record } | undefined; + const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.${params.channelKey}.accounts.${resolvedAccountId}.` + : `channels.${params.channelKey}.`; + const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`; + const policyPath = + params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined; + + return { + policy: params.policy ?? params.defaultPolicy ?? "pairing", + allowFrom: params.allowFrom ?? [], + policyPath, + allowFromPath, + approveHint: + params.approveHint ?? formatPairingApproveHint(params.approveChannelId ?? params.channelKey), + normalizeEntry: params.normalizeEntry, + }; +} + +function normalizeLocalE164(number: string): string { + const withoutPrefix = number.replace(/^whatsapp:/i, "").trim(); + const digits = withoutPrefix.replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) { + return `+${digits.slice(1)}`; + } + return `+${digits}`; +} + +function stripWhatsAppTargetPrefixes(value: string): string { + let candidate = value.trim(); + for (;;) { + const before = candidate; + candidate = candidate.replace(/^whatsapp:/i, "").trim(); + if (candidate === before) { + return candidate; + } + } +} + +function normalizeLocalWhatsAppTarget(value: string): string | null { + const candidate = stripWhatsAppTargetPrefixes(value); + if (!candidate) { + return null; + } + if (candidate.toLowerCase().endsWith("@g.us")) { + const localPart = candidate.slice(0, candidate.length - "@g.us".length); + return /^[0-9]+(-[0-9]+)*$/.test(localPart) ? `${localPart}@g.us` : null; + } + const userMatch = candidate.match(WHATSAPP_USER_JID_RE); + const lidMatch = candidate.match(WHATSAPP_LID_RE); + const phone = userMatch?.[1] ?? lidMatch?.[1]; + if (phone) { + const normalized = normalizeLocalE164(phone); + return normalized.length > 1 ? normalized : null; + } + if (candidate.includes("@")) { + return null; + } + const normalized = normalizeLocalE164(candidate); + return normalized.length > 1 ? normalized : null; +} + +function resolveChannelConfig( + cfg: OpenClawConfig, + channelId?: string | null, +): ChannelConfigWithAccounts | undefined { + if (!channelId) { + return undefined; + } + return (cfg.channels as Record | undefined)?.[channelId]; +} + +function resolveChannelAccountConfig( + channelConfig: ChannelConfigWithAccounts, + accountId?: string | null, +): AccountConfigWithWrites | undefined { + const normalized = normalizeAccountId(accountId); + return normalized ? channelConfig.accounts?.[normalized] : undefined; +} + +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; +} + +export function authorizeConfigWrite(params: { + cfg: OpenClawConfig; + origin?: ConfigWriteScope; + 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(); + 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 }; +} + +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 + ); +} + +export function formatConfigWriteDeniedMessage(params: { + result: Exclude; + 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..configWrites=true"; + return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`; +} + type ChannelConfigAccessorParams = { cfg: Config; accountId?: string | null; @@ -271,7 +494,7 @@ export function createScopedChannelConfigBase< inspectAccount: params.inspectAccount, defaultAccountId: params.defaultAccountId, setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ + setAccountEnabledInConfigSectionInSection({ cfg, sectionKey: params.sectionKey, accountId, @@ -279,7 +502,7 @@ export function createScopedChannelConfigBase< allowTopLevel: params.allowTopLevel ?? true, }), deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ + deleteAccountFromConfigSectionInSection({ cfg, sectionKey: params.sectionKey, accountId, @@ -491,7 +714,7 @@ export function createHybridChannelConfigBase< enabled, }); } - return setAccountEnabledInConfigSection({ + return setAccountEnabledInConfigSectionInSection({ cfg, sectionKey: params.sectionKey, accountId, @@ -507,14 +730,14 @@ export function createHybridChannelConfigBase< clearBaseFields: params.clearBaseFields, }); } - return deleteAccountFromConfigSection({ + return deleteAccountFromConfigSectionInSection({ cfg, sectionKey: params.sectionKey, accountId, clearBaseFields: params.clearBaseFields, }); } - return deleteAccountFromConfigSection({ + return deleteAccountFromConfigSectionInSection({ cfg, sectionKey: params.sectionKey, accountId, @@ -594,28 +817,45 @@ export function createScopedDmSecurityResolver< } export { buildAccountScopedDmSecurityPolicy }; -export { - collectAllowlistProviderGroupPolicyWarnings, - collectAllowlistProviderRestrictSendersWarnings, - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - collectOpenProviderGroupPolicyWarnings, -}; +function resolveMergedSimpleChannelAccountConfig(params: { + cfg: OpenClawConfig; + channelKey: string; + accountId?: string | null; + omitKeys?: string[]; +}): SimpleDirectMessageConfig { + const channelRoot = params.cfg.channels?.[params.channelKey] as + | SimpleScopedChannelConfig + | undefined; + return resolveMergedAccountConfig({ + channelConfig: channelRoot, + accounts: channelRoot?.accounts, + accountId: normalizeAccountId(params.accountId), + omitKeys: params.omitKeys, + }); +} -/** Read the effective WhatsApp allowlist through the active plugin contract. */ +/** Read the effective WhatsApp allowlist from merged root/account config without registry indirection. */ export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; }): string[] { - const account = getChannelPlugin("whatsapp")?.config.resolveAccount(params.cfg, params.accountId); - return account && typeof account === "object" && Array.isArray(account.allowFrom) - ? account.allowFrom.map(String) - : []; + return mapAllowFromEntries( + resolveMergedSimpleChannelAccountConfig({ + cfg: params.cfg, + channelKey: "whatsapp", + accountId: params.accountId, + omitKeys: ["defaultAccount"], + }).allowFrom, + ); } /** Format WhatsApp allowlist entries with the same normalization used by the channel plugin. */ export function formatWhatsAppConfigAllowFromEntries(allowFrom: Array): string[] { - return normalizeWhatsAppAllowFromEntries(allowFrom); + return allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => (entry === "*" ? entry : normalizeLocalWhatsAppTarget(entry))) + .filter((entry): entry is string => Boolean(entry)); } /** Resolve the effective WhatsApp default recipient after account and root config fallback. */ @@ -623,32 +863,40 @@ export function resolveWhatsAppConfigDefaultTo(params: { cfg: OpenClawConfig; accountId?: string | null; }): string | undefined { - const root = params.cfg.channels?.whatsapp; - const normalized = normalizeAccountId(params.accountId); - const account = root?.accounts?.[normalized]; - return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined; + return resolveOptionalConfigString( + resolveMergedSimpleChannelAccountConfig({ + cfg: params.cfg, + channelKey: "whatsapp", + accountId: params.accountId, + omitKeys: ["defaultAccount"], + }).defaultTo, + ); } -/** Read iMessage allowlist entries from the active plugin's resolved account view. */ +/** Read iMessage allowlist entries from merged root/account config without registry indirection. */ export function resolveIMessageConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; }): string[] { - const account = getChannelPlugin("imessage")?.config.resolveAccount(params.cfg, params.accountId); - if (!account || typeof account !== "object" || !("config" in account)) { - return []; - } - return mapAllowFromEntries(account.config.allowFrom); + return mapAllowFromEntries( + resolveMergedSimpleChannelAccountConfig({ + cfg: params.cfg, + channelKey: "imessage", + accountId: params.accountId, + }).allowFrom, + ); } -/** Resolve the effective iMessage default recipient from the plugin-resolved account config. */ +/** Resolve the effective iMessage default recipient from merged root/account config. */ export function resolveIMessageConfigDefaultTo(params: { cfg: OpenClawConfig; accountId?: string | null; }): string | undefined { - const account = getChannelPlugin("imessage")?.config.resolveAccount(params.cfg, params.accountId); - if (!account || typeof account !== "object" || !("config" in account)) { - return undefined; - } - return resolveOptionalConfigString(account.config.defaultTo); + return resolveOptionalConfigString( + resolveMergedSimpleChannelAccountConfig({ + cfg: params.cfg, + channelKey: "imessage", + accountId: params.accountId, + }).defaultTo, + ); } diff --git a/src/plugin-sdk/channel-config-writes.ts b/src/plugin-sdk/channel-config-writes.ts new file mode 100644 index 00000000000..009fa657074 --- /dev/null +++ b/src/plugin-sdk/channel-config-writes.ts @@ -0,0 +1,11 @@ +export { + authorizeConfigWrite, + canBypassConfigWritePolicy, + formatConfigWriteDeniedMessage, + resolveChannelConfigWrites, +} from "./channel-config-helpers.js"; +export type { + ConfigWriteAuthorizationResult, + ConfigWriteScope, + ConfigWriteTarget, +} from "./channel-config-helpers.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index d8ecce5a90f..4467a2681de 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -331,7 +331,6 @@ describe("plugin-sdk subpath exports", () => { "canonicalizeAllowlistWithResolvedIds", "mergeAllowlist", "patchAllowlistUsersInConfigEntries", - "resolveChannelConfigWrites", "resolvePayloadMediaUrls", "resolveScopedChannelMediaMaxBytes", "sendPayloadMediaSequenceAndFinalize", @@ -431,7 +430,7 @@ describe("plugin-sdk subpath exports", () => { "resolveChannelMatchConfig", "resolveTargetsWithOptionalToken", ]); - expectSourceMentions("channel-config-helpers", [ + expectSourceMentions("channel-config-writes", [ "authorizeConfigWrite", "canBypassConfigWritePolicy", "formatConfigWriteDeniedMessage",