From 05603e4e6ce6fc374474bf8022b75ebb6622298d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 04:51:01 +0000 Subject: [PATCH 001/565] refactor: deduplicate channel config adapters --- extensions/bluebubbles/src/channel.ts | 22 +-- extensions/discord/src/channel.ts | 4 +- extensions/discord/src/runtime-api.ts | 3 + extensions/discord/src/shared.ts | 18 +- extensions/feishu/src/channel.ts | 19 +-- extensions/googlechat/runtime-api.ts | 3 + extensions/googlechat/src/channel.ts | 26 ++- extensions/imessage/src/shared.ts | 19 +-- extensions/irc/src/channel.ts | 30 ++-- extensions/line/src/channel.setup.ts | 17 +- extensions/line/src/channel.ts | 31 +--- extensions/line/src/config-adapter.ts | 32 ++++ extensions/matrix/src/channel.ts | 23 ++- extensions/mattermost/src/channel.ts | 22 +-- extensions/msteams/src/channel.ts | 24 ++- extensions/nextcloud-talk/src/channel.ts | 27 ++- extensions/nostr/src/channel.ts | 60 ++++--- extensions/signal/src/channel.ts | 4 +- extensions/signal/src/shared.ts | 22 +-- extensions/slack/src/channel.ts | 4 +- extensions/slack/src/shared.ts | 20 +-- extensions/synology-chat/src/channel.test.ts | 10 ++ extensions/synology-chat/src/channel.ts | 9 +- extensions/telegram/src/channel.ts | 4 +- extensions/telegram/src/shared.ts | 22 +-- extensions/tlon/src/channel.test.ts | 32 ++++ extensions/tlon/src/channel.ts | 9 +- extensions/whatsapp/src/shared.ts | 12 +- extensions/zalo/src/channel.ts | 18 +- extensions/zalouser/src/shared.ts | 63 +++---- src/plugin-sdk/channel-config-helpers.test.ts | 152 +++++++++++++++++ src/plugin-sdk/channel-config-helpers.ts | 159 ++++++++++++++++++ src/plugin-sdk/compat.ts | 3 + src/plugin-sdk/subpaths.test.ts | 3 + 34 files changed, 605 insertions(+), 321 deletions(-) create mode 100644 extensions/line/src/config-adapter.ts create mode 100644 extensions/tlon/src/channel.test.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index cdc3a5bc567..33249fcfa9e 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,7 +1,6 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; @@ -47,8 +46,12 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( "blueBubblesChannelRuntime", ); -const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }), +const bluebubblesConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: "bluebubbles", + listAccountIds: listBlueBubblesAccountIds, + resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultBlueBubblesAccountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, formatAllowFrom: (allowFrom) => formatNormalizedAllowFromEntries({ @@ -57,14 +60,6 @@ const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({ }), }); -const bluebubblesConfigBase = createScopedChannelConfigBase({ - sectionKey: "bluebubbles", - listAccountIds: listBlueBubblesAccountIds, - resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultBlueBubblesAccountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], -}); - const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ channelKey: "bluebubbles", resolvePolicy: (account) => account.config.dmPolicy, @@ -115,7 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), setupWizard: blueBubblesSetupWizard, config: { - ...bluebubblesConfigBase, + ...bluebubblesConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -124,7 +119,6 @@ export const bluebubblesPlugin: ChannelPlugin = { configured: account.configured, baseUrl: account.baseUrl, }), - ...bluebubblesConfigAccessors, }, actions: bluebubblesMessageActions, security: { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 5116b559b60..1224fc7b37a 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -53,7 +53,7 @@ import { import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { discordSetupAdapter } from "./setup-core.js"; -import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js"; +import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -307,7 +307,7 @@ export const discordPlugin: ChannelPlugin = { applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ channelId: "discord", normalize: ({ cfg, accountId, values }) => - discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), resolvePaths: resolveLegacyDmAllowlistConfigPaths, }), }, diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index f2676220cdb..2aadbf90b9a 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -23,8 +23,11 @@ export { listDiscordDirectoryPeersFromConfig, } from "./directory-config.js"; export { + createHybridChannelConfigAdapter, + createScopedChannelConfigAdapter, createScopedAccountConfigAccessors, createScopedChannelConfigBase, + createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; export { createAccountActionGate, diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 242d2d163a7..eadb6241899 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -8,8 +8,7 @@ import { type ResolvedDiscordAccount, } from "./accounts.js"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, buildChannelConfigSchema, DiscordConfigSchema, getChatChannelMeta, @@ -27,20 +26,16 @@ export const discordSetupWizard = createDiscordSetupWizardProxy( async () => (await loadDiscordChannelRuntime()).discordSetupWizard, ); -export const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -export const discordConfigBase = createScopedChannelConfigBase({ +export const discordConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: DISCORD_CHANNEL, listAccountIds: listDiscordAccountIds, resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), defaultAccountId: resolveDefaultDiscordAccountId, clearBaseFields: ["token", "name"], + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, }); export function createDiscordPluginBase(params: { @@ -75,7 +70,7 @@ export function createDiscordPluginBase(params: { reload: { configPrefixes: ["channels.discord"] }, configSchema: buildChannelConfigSchema(DiscordConfigSchema), config: { - ...discordConfigBase, + ...discordConfigAdapter, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account) => ({ accountId: account.accountId, @@ -84,7 +79,6 @@ export function createDiscordPluginBase(params: { configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), - ...discordConfigAccessors, }, setup: params.setup, }) as Pick< diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index d56c80bb482..0aa071e7abd 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,8 +1,5 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createHybridChannelConfigBase, - createScopedAccountConfigAccessors, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -130,17 +127,16 @@ function setFeishuNamedAccountEnabled( }; } -const feishuConfigBase = createHybridChannelConfigBase({ +const feishuConfigAdapter = createHybridChannelConfigAdapter< + ResolvedFeishuAccount, + ResolvedFeishuAccount, + ClawdbotConfig +>({ sectionKey: "feishu", listAccountIds: listFeishuAccountIds, resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), defaultAccountId: resolveDefaultFeishuAccountId, clearBaseFields: [], -}); - -const feishuConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => - resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }), resolveAllowFrom: (account) => account.config.allowFrom, formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), }); @@ -396,7 +392,7 @@ export const feishuPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.feishu"] }, configSchema: buildChannelConfigSchema(FeishuConfigSchema), config: { - ...feishuConfigBase, + ...feishuConfigAdapter, setAccountEnabled: ({ cfg, accountId, enabled }) => { const isDefault = accountId === DEFAULT_ACCOUNT_ID; if (isDefault) { @@ -454,7 +450,6 @@ export const feishuPlugin: ChannelPlugin = { appId: account.appId, domain: account.domain, }), - ...feishuConfigAccessors, }, actions: { describeMessageTool: describeFeishuMessageTool, diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index fb71b4196fc..28f7c81c4e9 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -9,8 +9,11 @@ export { readStringParam, } from "../../src/agents/tools/common.js"; export { + createScopedChannelConfigAdapter, createScopedAccountConfigAccessors, createScopedChannelConfigBase, + createTopLevelChannelConfigAdapter, + createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "../../src/plugin-sdk/channel-config-helpers.js"; export { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index b82f7635ff1..7cc86e81cda 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,7 +1,6 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { @@ -61,18 +60,7 @@ const formatAllowFromEntry = (entry: string) => .replace(/^users\//i, "") .toLowerCase(); -const googleChatConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveGoogleChatAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: formatAllowFromEntry, - }), - resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo, -}); - -const googleChatConfigBase = createScopedChannelConfigBase({ +const googleChatConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: "googlechat", listAccountIds: listGoogleChatAccountIds, resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), @@ -87,6 +75,13 @@ const googleChatConfigBase = createScopedChannelConfigBase account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: formatAllowFromEntry, + }), + resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo, }); const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver({ @@ -146,7 +141,7 @@ export const googlechatPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.googlechat"] }, configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), config: { - ...googleChatConfigBase, + ...googleChatConfigAdapter, isConfigured: (account) => account.credentialSource !== "none", describeAccount: (account) => ({ accountId: account.accountId, @@ -155,7 +150,6 @@ export const googlechatPlugin: ChannelPlugin = { configured: account.credentialSource !== "none", credentialSource: account.credentialSource, }), - ...googleChatConfigAccessors, }, security: { resolveDmPolicy: resolveGoogleChatDmPolicy, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index fb2486c69f3..cf3e7b173cf 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,8 +1,8 @@ import { collectAllowlistProviderRestrictSendersWarnings, - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, + formatTrimmedAllowFromEntries, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { @@ -29,19 +29,15 @@ export const imessageSetupWizard = createIMessageSetupWizardProxy( async () => (await loadIMessageChannelRuntime()).imessageSetupWizard, ); -export const imessageConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo, -}); - -export const imessageConfigBase = createScopedChannelConfigBase({ +export const imessageConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: IMESSAGE_CHANNEL, listAccountIds: listIMessageAccountIds, resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), defaultAccountId: resolveDefaultIMessageAccountId, clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo, }); export const imessageResolveDmPolicy = createScopedDmSecurityResolver({ @@ -97,7 +93,7 @@ export function createIMessagePluginBase(params: { reload: { configPrefixes: ["channels.imessage"] }, configSchema: buildChannelConfigSchema(IMessageConfigSchema), config: { - ...imessageConfigBase, + ...imessageConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -105,7 +101,6 @@ export function createIMessagePluginBase(params: { enabled: account.enabled, configured: account.configured, }), - ...imessageConfigAccessors, }, security: { resolveDmPolicy: imessageResolveDmPolicy, diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 18fa8953045..554a01699ad 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,7 +1,6 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { @@ -51,18 +50,11 @@ function normalizePairingTarget(raw: string): string { return normalized.split(/[!@]/, 1)[0]?.trim() ?? ""; } -const ircConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), - resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: normalizeIrcAllowEntry, - }), - resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo, -}); - -const ircConfigBase = createScopedChannelConfigBase({ +const ircConfigAdapter = createScopedChannelConfigAdapter< + ResolvedIrcAccount, + ResolvedIrcAccount, + CoreConfig +>({ sectionKey: "irc", listAccountIds: listIrcAccountIds, resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }), @@ -79,6 +71,13 @@ const ircConfigBase = createScopedChannelConfigBase account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: normalizeIrcAllowEntry, + }), + resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo, }); const resolveIrcDmPolicy = createScopedDmSecurityResolver({ @@ -116,7 +115,7 @@ export const ircPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.irc"] }, configSchema: buildChannelConfigSchema(IrcConfigSchema), config: { - ...ircConfigBase, + ...ircConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -129,7 +128,6 @@ export const ircPlugin: ChannelPlugin = { nick: account.nick, passwordSource: account.passwordSource, }), - ...ircConfigAccessors, }, security: { resolveDmPolicy: resolveIrcDmPolicy, diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index 5df541d6286..bae717a205d 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -2,10 +2,9 @@ import { buildChannelConfigSchema, LineConfigSchema, type ChannelPlugin, - type OpenClawConfig, type ResolvedLineAccount, } from "../api.js"; -import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "../api.js"; +import { lineConfigAdapter } from "./config-adapter.js"; import { lineSetupAdapter } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; @@ -20,8 +19,6 @@ const meta = { systemImage: "message.fill", } as const; -const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, ""); - export const lineSetupPlugin: ChannelPlugin = { id: "line", meta: { @@ -39,10 +36,7 @@ export const lineSetupPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), config: { - listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg), - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => - resolveLineAccount({ cfg, accountId: accountId ?? undefined }), - defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg), + ...lineConfigAdapter, isConfigured: (account) => Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), describeAccount: (account) => ({ @@ -52,13 +46,6 @@ export const lineSetupPlugin: ChannelPlugin = { configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), tokenSource: account.tokenSource ?? undefined, }), - resolveAllowFrom: ({ cfg, accountId }) => - resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => normalizeLineAllowFrom(entry)), }, setupWizard: lineSetupWizard, setup: lineSetupAdapter, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 7d01f233371..cd3fab965cc 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,8 +1,4 @@ -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { buildChannelConfigSchema, @@ -14,11 +10,11 @@ import { processLineMessage, type ChannelPlugin, type ChannelStatusIssue, - type OpenClawConfig, type LineConfig, type LineChannelData, type ResolvedLineAccount, } from "../api.js"; +import { lineConfigAdapter } from "./config-adapter.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; @@ -36,26 +32,6 @@ const meta = { systemImage: "message.fill", }; -const lineConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => - getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }), - resolveAllowFrom: (account: ResolvedLineAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => entry.replace(/^line:(?:user:)?/i, "")), -}); - -const lineConfigBase = createScopedChannelConfigBase({ - sectionKey: "line", - listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), - resolveAccount: (cfg, accountId) => - getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }), - defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), - clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], -}); - const resolveLineDmPolicy = createScopedDmSecurityResolver({ channelKey: "line", resolvePolicy: (account) => account.config.dmPolicy, @@ -100,7 +76,7 @@ export const linePlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(LineConfigSchema), setupWizard: lineSetupWizard, config: { - ...lineConfigBase, + ...lineConfigAdapter, isConfigured: (account) => Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), describeAccount: (account) => ({ @@ -110,7 +86,6 @@ export const linePlugin: ChannelPlugin = { configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), tokenSource: account.tokenSource ?? undefined, }), - ...lineConfigAccessors, }, security: { resolveDmPolicy: resolveLineDmPolicy, diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts new file mode 100644 index 00000000000..118159f16b2 --- /dev/null +++ b/extensions/line/src/config-adapter.ts @@ -0,0 +1,32 @@ +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import type { OpenClawConfig, ResolvedLineAccount } from "../api.js"; +import { getLineRuntime } from "./runtime.js"; + +function resolveLineRuntimeAccount(cfg: OpenClawConfig, accountId?: string | null) { + return getLineRuntime().channel.line.resolveLineAccount({ + cfg, + accountId: accountId ?? undefined, + }); +} + +export function normalizeLineAllowFrom(entry: string): string { + return entry.replace(/^line:(?:user:)?/i, ""); +} + +export const lineConfigAdapter = createScopedChannelConfigAdapter< + ResolvedLineAccount, + ResolvedLineAccount, + OpenClawConfig +>({ + sectionKey: "line", + listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveLineRuntimeAccount(cfg, accountId), + defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), + clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map(normalizeLineAllowFrom), +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 7a3f485d21d..aaf18e3f94b 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,6 +1,5 @@ import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { @@ -69,17 +68,16 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined { return stripped || undefined; } -const matrixConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), - resolveAllowFrom: (account) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom), -}); - -const matrixConfigBase = createScopedChannelConfigBase({ +const matrixConfigAdapter = createScopedChannelConfigAdapter< + ResolvedMatrixAccount, + ReturnType, + CoreConfig +>({ sectionKey: "matrix", listAccountIds: listMatrixAccountIds, resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }), + resolveAccessorAccount: ({ cfg, accountId }) => + resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), defaultAccountId: resolveDefaultMatrixAccountId, clearBaseFields: [ "name", @@ -90,6 +88,8 @@ const matrixConfigBase = createScopedChannelConfigBase account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom), }); const resolveMatrixDmPolicy = createScopedDmSecurityResolver({ @@ -122,7 +122,7 @@ export const matrixPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.matrix"] }, configSchema: buildChannelConfigSchema(MatrixConfigSchema), config: { - ...matrixConfigBase, + ...matrixConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -131,7 +131,6 @@ export const matrixPlugin: ChannelPlugin = { configured: account.configured, baseUrl: account.homeserver, }), - ...matrixConfigAccessors, }, security: { resolveDmPolicy: resolveMatrixDmPolicy, diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 90d24e11406..8c32e068165 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -1,7 +1,6 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; @@ -248,8 +247,12 @@ function formatAllowEntry(entry: string): string { return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase(); } -const mattermostConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveMattermostAccount({ cfg, accountId }), +const mattermostConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: "mattermost", + listAccountIds: listMattermostAccountIds, + resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultMattermostAccountId, + clearBaseFields: ["botToken", "baseUrl", "name"], resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom, formatAllowFrom: (allowFrom) => formatNormalizedAllowFromEntries({ @@ -258,14 +261,6 @@ const mattermostConfigAccessors = createScopedAccountConfigAccessors({ }), }); -const mattermostConfigBase = createScopedChannelConfigBase({ - sectionKey: "mattermost", - listAccountIds: listMattermostAccountIds, - resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultMattermostAccountId, - clearBaseFields: ["botToken", "baseUrl", "name"], -}); - const resolveMattermostDmPolicy = createScopedDmSecurityResolver({ channelKey: "mattermost", resolvePolicy: (account) => account.config.dmPolicy, @@ -311,7 +306,7 @@ export const mattermostPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), config: { - ...mattermostConfigBase, + ...mattermostConfigAdapter, isConfigured: (account) => Boolean(account.botToken && account.baseUrl), describeAccount: (account) => ({ accountId: account.accountId, @@ -321,7 +316,6 @@ export const mattermostPlugin: ChannelPlugin = { botTokenSource: account.botTokenSource, baseUrl: account.baseUrl, }), - ...mattermostConfigAccessors, }, security: { resolveDmPolicy: resolveMattermostDmPolicy, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 17c73cf1e61..b1379e311df 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,8 +1,5 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createTopLevelChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -73,20 +70,20 @@ const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({ defaultTo: cfg.channels?.msteams?.defaultTo, }); -const msteamsConfigBase = createTopLevelChannelConfigBase({ +const msteamsConfigAdapter = createTopLevelChannelConfigAdapter< + ResolvedMSTeamsAccount, + { + allowFrom?: Array; + defaultTo?: string; + } +>({ sectionKey: "msteams", resolveAccount: (cfg) => ({ accountId: DEFAULT_ACCOUNT_ID, enabled: cfg.channels?.msteams?.enabled !== false, configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), }), -}); - -const msteamsConfigAccessors = createScopedAccountConfigAccessors<{ - allowFrom?: Array; - defaultTo?: string; -}>({ - resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), + resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), resolveAllowFrom: (account) => account.allowFrom, formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), resolveDefaultTo: (account) => account.defaultTo, @@ -157,14 +154,13 @@ export const msteamsPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.msteams"] }, configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), config: { - ...msteamsConfigBase, + ...msteamsConfigAdapter, isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), describeAccount: (account) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, }), - ...msteamsConfigAccessors, }, security: { collectWarnings: ({ cfg }) => { diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index d912a6bbf33..ce2f281a3e6 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -1,7 +1,6 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; @@ -51,19 +50,8 @@ const meta = { quickstartAllowFrom: true, }; -const nextcloudTalkConfigAccessors = - createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => - resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), - resolveAllowFrom: (account) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ - allowFrom, - stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, - }), - }); - -const nextcloudTalkConfigBase = createScopedChannelConfigBase< +const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter< + ResolvedNextcloudTalkAccount, ResolvedNextcloudTalkAccount, CoreConfig >({ @@ -72,6 +60,12 @@ const nextcloudTalkConfigBase = createScopedChannelConfigBase< resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }), defaultAccountId: resolveDefaultNextcloudTalkAccountId, clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ + allowFrom, + stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, + }), }); const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver({ @@ -105,7 +99,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = reload: { configPrefixes: ["channels.nextcloud-talk"] }, configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema), config: { - ...nextcloudTalkConfigBase, + ...nextcloudTalkConfigAdapter, isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()), describeAccount: (account) => ({ accountId: account.accountId, @@ -115,7 +109,6 @@ export const nextcloudTalkPlugin: ChannelPlugin = secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", }), - ...nextcloudTalkConfigAccessors, }, security: { resolveDmPolicy: resolveNextcloudTalkDmPolicy, diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 7758e1a18ab..63ea3436dab 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,11 +1,13 @@ -import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createScopedDmSecurityResolver, + createTopLevelChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, - mapAllowFromEntries, type ChannelPlugin, } from "openclaw/plugin-sdk/nostr"; import { @@ -49,6 +51,39 @@ const resolveNostrDmPolicy = createScopedDmSecurityResolver({ + sectionKey: "nostr", + resolveAccount: (cfg) => resolveNostrAccount({ cfg }), + listAccountIds: listNostrAccountIds, + defaultAccountId: resolveDefaultNostrAccountId, + deleteMode: "clear-fields", + clearBaseFields: [ + "name", + "defaultAccount", + "privateKey", + "relays", + "dmPolicy", + "allowFrom", + "profile", + ], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "*") { + return "*"; + } + try { + return normalizePubkey(entry); + } catch { + return entry; + } + }) + .filter(Boolean), +}); + export const nostrPlugin: ChannelPlugin = { id: "nostr", meta: { @@ -70,9 +105,7 @@ export const nostrPlugin: ChannelPlugin = { setupWizard: nostrSetupWizard, config: { - listAccountIds: (cfg) => listNostrAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg), + ...nostrConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -81,23 +114,6 @@ export const nostrPlugin: ChannelPlugin = { configured: account.configured, publicKey: account.publicKey, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveNostrAccount({ cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => { - if (entry === "*") { - return "*"; - } - try { - return normalizePubkey(entry); - } catch { - return entry; // Keep as-is if normalization fails - } - }) - .filter(Boolean), }, pairing: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8552a26c8df..8b8fe842511 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -31,8 +31,8 @@ import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; import { collectSignalSecurityWarnings, + signalConfigAdapter, createSignalPluginBase, - signalConfigAccessors, signalResolveDmPolicy, signalSetupWizard, } from "./shared.js"; @@ -290,7 +290,7 @@ export const signalPlugin: ChannelPlugin = { applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ channelId: "signal", normalize: ({ cfg, accountId, values }) => - signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), resolvePaths: (scope) => ({ readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index e9370474de2..c307a51e66c 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -1,7 +1,6 @@ import { collectAllowlistProviderRestrictSendersWarnings, - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; @@ -30,8 +29,12 @@ export const signalSetupWizard = createSignalSetupWizardProxy( async () => (await loadSignalChannelRuntime()).signalSetupWizard, ); -export const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), +export const signalConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: SIGNAL_CHANNEL, + listAccountIds: listSignalAccountIds, + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSignalAccountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, formatAllowFrom: (allowFrom) => allowFrom @@ -42,14 +45,6 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, }); -export const signalConfigBase = createScopedChannelConfigBase({ - sectionKey: SIGNAL_CHANNEL, - listAccountIds: listSignalAccountIds, - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSignalAccountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], -}); - export const signalResolveDmPolicy = createScopedDmSecurityResolver({ channelKey: SIGNAL_CHANNEL, resolvePolicy: (account) => account.config.dmPolicy, @@ -107,7 +102,7 @@ export function createSignalPluginBase(params: { reload: { configPrefixes: ["channels.signal"] }, configSchema: buildChannelConfigSchema(SignalConfigSchema), config: { - ...signalConfigBase, + ...signalConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -116,7 +111,6 @@ export function createSignalPluginBase(params: { configured: account.configured, baseUrl: account.baseUrl, }), - ...signalConfigAccessors, }, security: { resolveDmPolicy: signalResolveDmPolicy, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index fac44a19770..417f3b9a3b4 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -44,7 +44,7 @@ import { slackSetupWizard } from "./setup-surface.js"; import { createSlackPluginBase, isSlackPluginAccountConfigured, - slackConfigAccessors, + slackConfigAdapter, SLACK_CHANNEL, } from "./shared.js"; import { parseSlackTarget } from "./targets.js"; @@ -352,7 +352,7 @@ export const slackPlugin: ChannelPlugin = { applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ channelId: "slack", normalize: ({ cfg, accountId, values }) => - slackConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), resolvePaths: resolveLegacyDmAllowlistConfigPaths, }), }, diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index dea94f67d3d..0d7e72a30e1 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,8 +1,5 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { formatDocsLink, @@ -145,20 +142,16 @@ export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): bo return hasConfiguredBotToken && hasConfiguredAppToken; } -export const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, -}); - -export const slackConfigBase = createScopedChannelConfigBase({ +export const slackConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: SLACK_CHANNEL, listAccountIds: listSlackAccountIds, resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, }); export function createSlackPluginBase(params: { @@ -208,7 +201,7 @@ export function createSlackPluginBase(params: { reload: { configPrefixes: ["channels.slack"] }, configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { - ...slackConfigBase, + ...slackConfigAdapter, isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, @@ -218,7 +211,6 @@ export function createSlackPluginBase(params: { botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }), - ...slackConfigAccessors, }, setup: params.setup, }) as Pick< diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 851b6e92561..3c453d0613a 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -57,6 +57,16 @@ describe("createSynologyChatPlugin", () => { const plugin = createSynologyChatPlugin(); expect(plugin.config.defaultAccountId?.({})).toBe("default"); }); + + it("formats allowFrom entries through the shared adapter", () => { + const plugin = createSynologyChatPlugin(); + expect( + plugin.config.formatAllowFrom?.({ + cfg: {}, + allowFrom: [" USER1 ", 42], + }), + ).toEqual(["user1", "42"]); + }); }); describe("security", () => { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 3a3cbb99eb2..496b5563857 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -5,7 +5,7 @@ */ import { - createHybridChannelConfigBase, + createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { z } from "zod"; @@ -32,7 +32,7 @@ const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver raw.toLowerCase().trim(), }); -const synologyChatConfigBase = createHybridChannelConfigBase({ +const synologyChatConfigAdapter = createHybridChannelConfigAdapter({ sectionKey: CHANNEL_ID, listAccountIds: (cfg: any) => listAccountIds(cfg), resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId), @@ -48,6 +48,9 @@ const synologyChatConfigBase = createHybridChannelConfigBase account.allowedUserIds, + formatAllowFrom: (allowFrom) => + allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean), }); function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { @@ -100,7 +103,7 @@ export function createSynologyChatPlugin() { setupWizard: synologyChatSetupWizard, config: { - ...synologyChatConfigBase, + ...synologyChatConfigAdapter, }, pairing: { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index f9946dfa1d6..3313510ad16 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -55,7 +55,7 @@ import { createTelegramPluginBase, findTelegramTokenOwnerAccountId, formatDuplicateTelegramTokenReason, - telegramConfigAccessors, + telegramConfigAdapter, } from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -325,7 +325,7 @@ export const telegramPlugin: ChannelPlugin - telegramConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), resolvePaths: (scope) => ({ readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index e75c17ed7b4..6898870e394 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -1,8 +1,5 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, @@ -56,21 +53,17 @@ export function formatDuplicateTelegramTokenReason(params: { ); } -export const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -export const telegramConfigBase = createScopedChannelConfigBase({ +export const telegramConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: TELEGRAM_CHANNEL, listAccountIds: listTelegramAccountIds, resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), defaultAccountId: resolveDefaultTelegramAccountId, clearBaseFields: ["botToken", "tokenFile", "name"], + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, }); export function createTelegramPluginBase(params: { @@ -99,7 +92,7 @@ export function createTelegramPluginBase(params: { reload: { configPrefixes: ["channels.telegram"] }, configSchema: buildChannelConfigSchema(TelegramConfigSchema), config: { - ...telegramConfigBase, + ...telegramConfigAdapter, isConfigured: (account, cfg) => { if (!account.token?.trim()) { return false; @@ -131,7 +124,6 @@ export function createTelegramPluginBase(params: { !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), tokenSource: account.tokenSource, }), - ...telegramConfigAccessors, }, setup: params.setup, }) as Pick< diff --git a/extensions/tlon/src/channel.test.ts b/extensions/tlon/src/channel.test.ts new file mode 100644 index 00000000000..44059ed1617 --- /dev/null +++ b/extensions/tlon/src/channel.test.ts @@ -0,0 +1,32 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; +import { describe, expect, it } from "vitest"; +import { tlonPlugin } from "./channel.js"; + +describe("tlonPlugin config", () => { + it("formats dm allowlist entries through the shared hybrid adapter", () => { + expect( + tlonPlugin.config.formatAllowFrom?.({ + cfg: {} as OpenClawConfig, + allowFrom: ["zod", " ~nec "], + }), + ).toEqual(["~zod", "~nec"]); + }); + + it("resolves dm allowlist from the default account", () => { + expect( + tlonPlugin.config.resolveAllowFrom?.({ + cfg: { + channels: { + tlon: { + ship: "~sampel-palnet", + url: "https://urbit.example.com", + code: "lidlut-tabwed-pillex-ridrup", + dmAllowlist: ["~zod"], + }, + }, + } as OpenClawConfig, + accountId: "default", + }), + ).toEqual(["~zod"]); + }); +}); diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 794dbd4f5e0..865ead9ab46 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,4 +1,4 @@ -import { createHybridChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -39,7 +39,7 @@ const tlonSetupWizardProxy = createTlonSetupWizardBase({ ).tlonSetupWizard.finalize!(params), }) satisfies NonNullable; -const tlonConfigBase = createHybridChannelConfigBase({ +const tlonConfigAdapter = createHybridChannelConfigAdapter({ sectionKey: TLON_CHANNEL_ID, listAccountIds: (cfg: OpenClawConfig) => listTlonAccountIds(cfg), resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => @@ -47,6 +47,9 @@ const tlonConfigBase = createHybridChannelConfigBase({ defaultAccountId: () => "default", clearBaseFields: ["ship", "code", "url", "name"], preserveSectionOnDefaultDelete: true, + resolveAllowFrom: (account) => account.dmAllowlist, + formatAllowFrom: (allowFrom) => + allowFrom.map((entry) => normalizeShip(String(entry))).filter(Boolean), }); export const tlonPlugin: ChannelPlugin = { @@ -72,7 +75,7 @@ export const tlonPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { - ...tlonConfigBase, + ...tlonConfigAdapter, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 2854db5d61f..9c3e3d50acf 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,8 +1,7 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; @@ -37,17 +36,13 @@ export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy( async () => (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, ); -const whatsappConfigBase = createScopedChannelConfigBase({ +const whatsappConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: WHATSAPP_CHANNEL, listAccountIds: listWhatsAppAccountIds, resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), defaultAccountId: resolveDefaultWhatsAppAccountId, clearBaseFields: [], allowTopLevel: false, -}); - -const whatsappConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }), resolveAllowFrom: (account) => account.allowFrom, formatAllowFrom: (allowFrom) => formatWhatsAppConfigAllowFromEntries(allowFrom), resolveDefaultTo: (account) => account.defaultTo, @@ -133,7 +128,7 @@ export function createWhatsAppPluginBase(params: { gatewayMethods: ["web.login.start", "web.login.wait"], configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), config: { - ...whatsappConfigBase, + ...whatsappConfigAdapter, isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, disabledReason: () => "disabled", isConfigured: params.isConfigured, @@ -147,7 +142,6 @@ export function createWhatsAppPluginBase(params: { dmPolicy: account.dmPolicy, allowFrom: account.allowFrom, }), - ...whatsappConfigAccessors, }, security: { resolveDmPolicy: whatsappResolveDmPolicy, diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index db34bb25400..57f74ca01d2 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -1,6 +1,5 @@ import { - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, + createScopedChannelConfigAdapter, createScopedDmSecurityResolver, mapAllowFromEntries, } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -62,19 +61,15 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); -const zaloConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveZaloAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), -}); - -const zaloConfigBase = createScopedChannelConfigBase({ +const zaloConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: "zalo", listAccountIds: listZaloAccountIds, resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), defaultAccountId: resolveDefaultZaloAccountId, clearBaseFields: ["botToken", "tokenFile", "name"], + resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), }); const resolveZaloDmPolicy = createScopedDmSecurityResolver({ @@ -102,7 +97,7 @@ export const zaloPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.zalo"] }, configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { - ...zaloConfigBase, + ...zaloConfigAdapter, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -111,7 +106,6 @@ export const zaloPlugin: ChannelPlugin = { configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), - ...zaloConfigAccessors, }, security: { resolveDmPolicy: resolveZaloDmPolicy, diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts index bac69441806..c48c80b4903 100644 --- a/extensions/zalouser/src/shared.ts +++ b/extensions/zalouser/src/shared.ts @@ -1,11 +1,6 @@ -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser"; -import { - buildChannelConfigSchema, - deleteAccountFromConfigSection, - formatAllowFromLowercase, - setAccountEnabledInConfigSection, -} from "openclaw/plugin-sdk/zalouser"; +import { buildChannelConfigSchema, formatAllowFromLowercase } from "openclaw/plugin-sdk/zalouser"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, @@ -27,6 +22,27 @@ export const zalouserMeta = { quickstartAllowFrom: false, } satisfies ChannelPlugin["meta"]; +const zalouserConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: "zalouser", + listAccountIds: listZalouserAccountIds, + resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg, accountId }), + defaultAccountId: resolveDefaultZalouserAccountId, + clearBaseFields: [ + "profile", + "name", + "dmPolicy", + "allowFrom", + "historyLimit", + "groupAllowFrom", + "groupPolicy", + "groups", + "messagePrefix", + ], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), +}); + export function createZalouserPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; @@ -50,34 +66,7 @@ export function createZalouserPluginBase(params: { reload: { configPrefixes: ["channels.zalouser"] }, configSchema: buildChannelConfigSchema(ZalouserConfigSchema), config: { - listAccountIds: (cfg) => listZalouserAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "zalouser", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "zalouser", - accountId, - clearBaseFields: [ - "profile", - "name", - "dmPolicy", - "allowFrom", - "historyLimit", - "groupAllowFrom", - "groupPolicy", - "groups", - "messagePrefix", - ], - }), + ...zalouserConfigAdapter, isConfigured: async (account) => await checkZcaAuthenticated(account.profile), describeAccount: (account) => ({ accountId: account.accountId, @@ -85,10 +74,6 @@ export function createZalouserPluginBase(params: { enabled: account.enabled, configured: undefined, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZalouserAccountSync({ cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }), }, setup: params.setup, }; diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 7753c4c745e..296b8bf9a8e 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { createScopedAccountConfigAccessors, + createScopedChannelConfigAdapter, createScopedChannelConfigBase, createScopedDmSecurityResolver, + createHybridChannelConfigAdapter, + createTopLevelChannelConfigAdapter, createTopLevelChannelConfigBase, createHybridChannelConfigBase, mapAllowFromEntries, @@ -160,6 +163,41 @@ describe("createScopedChannelConfigBase", () => { }); }); +describe("createScopedChannelConfigAdapter", () => { + it("combines scoped CRUD and allowFrom accessors", () => { + const adapter = createScopedChannelConfigAdapter({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ + accountId: accountId ?? "default", + allowFrom: accountId ? [accountId] : ["fallback"], + defaultTo: " room:123 ", + }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).toUpperCase()), + resolveDefaultTo: (account) => account.defaultTo, + }); + + expect(adapter.listAccountIds({})).toEqual(["default", "alt"]); + expect(adapter.resolveAccount({}, "alt")).toEqual({ + accountId: "alt", + allowFrom: ["alt"], + defaultTo: " room:123 ", + }); + expect(adapter.resolveAllowFrom?.({ cfg: {}, accountId: "alt" })).toEqual(["alt"]); + expect(adapter.resolveDefaultTo?.({ cfg: {}, accountId: "alt" })).toBe("room:123"); + expect( + adapter.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + }); +}); + describe("createScopedDmSecurityResolver", () => { it("builds account-aware DM policy payloads", () => { const resolveDmPolicy = createScopedDmSecurityResolver<{ @@ -232,6 +270,69 @@ describe("createTopLevelChannelConfigBase", () => { }).channels, ).toBeUndefined(); }); + + it("can clear only account-scoped fields while preserving channel settings", () => { + const base = createTopLevelChannelConfigBase({ + sectionKey: "demo", + resolveAccount: () => ({ accountId: "default" }), + deleteMode: "clear-fields", + clearBaseFields: ["token", "allowFrom"], + }); + + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + allowFrom: ["owner"], + markdown: { tables: false }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + markdown: { tables: false }, + }); + }); +}); + +describe("createTopLevelChannelConfigAdapter", () => { + it("combines top-level CRUD with separate accessor account resolution", () => { + const adapter = createTopLevelChannelConfigAdapter< + { accountId: string; enabled: boolean }, + { allowFrom: string[]; defaultTo: string } + >({ + sectionKey: "demo", + resolveAccount: () => ({ accountId: "default", enabled: true }), + resolveAccessorAccount: () => ({ allowFrom: ["owner"], defaultTo: " chat:123 " }), + deleteMode: "clear-fields", + clearBaseFields: ["token"], + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry)), + resolveDefaultTo: (account) => account.defaultTo, + }); + + expect(adapter.resolveAccount({})).toEqual({ accountId: "default", enabled: true }); + expect(adapter.resolveAllowFrom?.({ cfg: {} })).toEqual(["owner"]); + expect(adapter.resolveDefaultTo?.({ cfg: {} })).toBe("chat:123"); + expect( + adapter.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + markdown: { tables: false }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + markdown: { tables: false }, + }); + }); }); describe("createHybridChannelConfigBase", () => { @@ -309,3 +410,54 @@ describe("createHybridChannelConfigBase", () => { }); }); }); + +describe("createHybridChannelConfigAdapter", () => { + it("combines hybrid CRUD with allowFrom/defaultTo accessors", () => { + const adapter = createHybridChannelConfigAdapter< + { accountId: string; enabled: boolean }, + { allowFrom: string[]; defaultTo: string } + >({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ + accountId: accountId ?? "default", + enabled: true, + }), + resolveAccessorAccount: ({ accountId }) => ({ + allowFrom: [accountId ?? "default"], + defaultTo: " room:123 ", + }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + preserveSectionOnDefaultDelete: true, + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).toUpperCase()), + resolveDefaultTo: (account) => account.defaultTo, + }); + + expect(adapter.resolveAllowFrom?.({ cfg: {}, accountId: "alt" })).toEqual(["alt"]); + expect(adapter.resolveDefaultTo?.({ cfg: {}, accountId: "alt" })).toBe("room:123"); + expect( + adapter.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + expect( + adapter.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + markdown: { tables: false }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + markdown: { tables: false }, + }); + }); +}); diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index af6813e13a1..ee18f8bc9c9 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -116,6 +116,59 @@ export function createScopedChannelConfigBase< }; } +/** Build the full shared config adapter for account-scoped channels with allowlist/default target accessors. */ +export function createScopedChannelConfigAdapter< + ResolvedAccount, + AccessorAccount = ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + allowTopLevel?: boolean; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" + | "resolveAllowFrom" + | "formatAllowFrom" + | "resolveDefaultTo" +> { + const resolveAccessorAccount = + params.resolveAccessorAccount ?? + (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); + + return { + ...createScopedChannelConfigBase({ + sectionKey: params.sectionKey, + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, + clearBaseFields: params.clearBaseFields, + allowTopLevel: params.allowTopLevel, + }), + ...createScopedAccountConfigAccessors({ + resolveAccount: resolveAccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }), + }; +} + function setTopLevelChannelEnabledInConfigSection(params: { cfg: Config; sectionKey: string; @@ -219,6 +272,59 @@ export function createTopLevelChannelConfigBase< }; } +/** Build the full shared config adapter for top-level single-account channels with allowlist/default target accessors. */ +export function createTopLevelChannelConfigAdapter< + ResolvedAccount, + AccessorAccount = ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + resolveAccount: (cfg: Config) => ResolvedAccount; + resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; + listAccountIds?: (cfg: Config) => string[]; + defaultAccountId?: (cfg: Config) => string; + inspectAccount?: (cfg: Config) => unknown; + deleteMode?: "remove-section" | "clear-fields"; + clearBaseFields?: string[]; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" + | "resolveAllowFrom" + | "formatAllowFrom" + | "resolveDefaultTo" +> { + const resolveAccessorAccount = + params.resolveAccessorAccount ?? + (({ cfg }: { cfg: Config; accountId?: string | null }) => + params.resolveAccount(cfg) as unknown as AccessorAccount); + + return { + ...createTopLevelChannelConfigBase({ + sectionKey: params.sectionKey, + resolveAccount: params.resolveAccount, + listAccountIds: params.listAccountIds, + defaultAccountId: params.defaultAccountId, + inspectAccount: params.inspectAccount, + deleteMode: params.deleteMode, + clearBaseFields: params.clearBaseFields, + }), + ...createScopedAccountConfigAccessors({ + resolveAccount: resolveAccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }), + }; +} + /** Build CRUD/config helpers for channels where the default account lives at channel root and named accounts live under `accounts`. */ export function createHybridChannelConfigBase< ResolvedAccount, @@ -288,6 +394,59 @@ export function createHybridChannelConfigBase< }; } +/** Build the full shared config adapter for hybrid channels with allowlist/default target accessors. */ +export function createHybridChannelConfigAdapter< + ResolvedAccount, + AccessorAccount = ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + resolveAccessorAccount?: (params: { cfg: Config; accountId?: string | null }) => AccessorAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + preserveSectionOnDefaultDelete?: boolean; + resolveAllowFrom: (account: AccessorAccount) => Array | null | undefined; + formatAllowFrom: (allowFrom: Array) => string[]; + resolveDefaultTo?: (account: AccessorAccount) => string | number | null | undefined; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" + | "resolveAllowFrom" + | "formatAllowFrom" + | "resolveDefaultTo" +> { + const resolveAccessorAccount = + params.resolveAccessorAccount ?? + (({ cfg, accountId }: { cfg: Config; accountId?: string | null }) => + params.resolveAccount(cfg, accountId) as unknown as AccessorAccount); + + return { + ...createHybridChannelConfigBase({ + sectionKey: params.sectionKey, + listAccountIds: params.listAccountIds, + resolveAccount: params.resolveAccount, + inspectAccount: params.inspectAccount, + defaultAccountId: params.defaultAccountId, + clearBaseFields: params.clearBaseFields, + preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete, + }), + ...createScopedAccountConfigAccessors({ + resolveAccount: resolveAccessorAccount, + resolveAllowFrom: params.resolveAllowFrom, + formatAllowFrom: params.formatAllowFrom, + resolveDefaultTo: params.resolveDefaultTo, + }), + }; +} + /** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ export function createScopedDmSecurityResolver< ResolvedAccount extends { accountId?: string | null }, diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 2ce0c6a2c3b..9892bbc8fc7 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -25,10 +25,13 @@ export { createPluginRuntimeStore } from "./runtime-store.js"; export { KeyedAsyncQueue } from "./keyed-async-queue.js"; export { + createHybridChannelConfigAdapter, createHybridChannelConfigBase, createScopedAccountConfigAccessors, + createScopedChannelConfigAdapter, createScopedChannelConfigBase, createScopedDmSecurityResolver, + createTopLevelChannelConfigAdapter, createTopLevelChannelConfigBase, mapAllowFromEntries, } from "./channel-config-helpers.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 1b31ed580e4..606e7b623f8 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -62,6 +62,9 @@ describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { expect(typeof compatSdk.emptyPluginConfigSchema).toBe("function"); expect(typeof compatSdk.resolveControlCommandGate).toBe("function"); + expect(typeof compatSdk.createScopedChannelConfigAdapter).toBe("function"); + expect(typeof compatSdk.createTopLevelChannelConfigAdapter).toBe("function"); + expect(typeof compatSdk.createHybridChannelConfigAdapter).toBe("function"); }); it("keeps core focused on generic shared exports", () => { From 67ce726bba256ce8d0b4a2c53a5a36263a68f2a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 04:52:13 +0000 Subject: [PATCH 002/565] fix(slack): repair gateway watch runtime export --- extensions/slack/src/runtime-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index bbaca99c879..4988fa5d4f4 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -12,7 +12,7 @@ export { buildComputedAccountStatusSnapshot } from "../../../src/plugin-sdk/stat export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, -} from "../../../src/channels/plugins/directory-config.js"; +} from "./directory-config.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, From 0354d49a82c1e26c2f4e0c59462e988d0e107c63 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:59:17 -0500 Subject: [PATCH 003/565] docs update web search config guidance --- docs/brave-search.md | 15 +- docs/cli/config.md | 2 +- docs/gateway/configuration.md | 2 +- docs/help/faq.md | 15 +- docs/ja-JP/start/wizard.md | 2 +- docs/perplexity.md | 46 ++++-- docs/reference/api-usage-costs.md | 12 +- .../reference/secretref-credential-surface.md | 12 +- ...tref-user-supplied-credentials-matrix.json | 27 ++-- docs/tools/firecrawl.md | 22 +-- docs/tools/web.md | 140 ++++++++++++------ docs/zh-CN/brave-search.md | 14 +- docs/zh-CN/cli/config.md | 2 +- docs/zh-CN/gateway/configuration.md | 2 +- docs/zh-CN/help/faq.md | 16 +- docs/zh-CN/perplexity.md | 38 +++-- docs/zh-CN/reference/api-usage-costs.md | 9 +- docs/zh-CN/tools/web.md | 82 ++++++---- extensions/firecrawl/src/firecrawl-client.ts | 2 +- extensions/xai/web-search.ts | 2 +- 20 files changed, 325 insertions(+), 137 deletions(-) diff --git a/docs/brave-search.md b/docs/brave-search.md index 4a541690431..12cd78c358f 100644 --- a/docs/brave-search.md +++ b/docs/brave-search.md @@ -20,11 +20,21 @@ OpenClaw supports Brave Search API as a `web_search` provider. ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "BRAVE_API_KEY_HERE", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "brave", - apiKey: "BRAVE_API_KEY_HERE", maxResults: 5, timeoutSeconds: 30, }, @@ -33,6 +43,9 @@ OpenClaw supports Brave Search API as a `web_search` provider. } ``` +Provider-specific Brave search settings now live under `plugins.entries.brave.config.webSearch.*`. +Legacy `tools.web.search.apiKey` still loads through the compatibility shim, but it is no longer the canonical config path. + ## Tool parameters | Parameter | Description | diff --git a/docs/cli/config.md b/docs/cli/config.md index 72ba3af0c9d..1eb376f0fa0 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -21,7 +21,7 @@ openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json -openclaw config unset tools.web.search.apiKey +openclaw config unset plugins.entries.brave.config.webSearch.apiKey openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run openclaw config validate openclaw config validate --json diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d15efb3384b..b8977ca10ac 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -46,7 +46,7 @@ See the [full reference](/gateway/configuration-reference) for every available f ```bash openclaw config get agents.defaults.workspace openclaw config set agents.defaults.heartbeat.every "2h" - openclaw config unset tools.web.search.apiKey + openclaw config unset plugins.entries.brave.config.webSearch.apiKey ``` diff --git a/docs/help/faq.md b/docs/help/faq.md index cc52aafd604..49b19708cc7 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1505,12 +1505,22 @@ Environment alternatives: ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "BRAVE_API_KEY_HERE", + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, provider: "brave", - apiKey: "BRAVE_API_KEY_HERE", maxResults: 5, }, fetch: { @@ -1521,6 +1531,9 @@ Environment alternatives: } ``` +Provider-specific web-search config now lives under `plugins.entries..config.webSearch.*`. +Legacy `tools.web.search.*` provider paths still load temporarily for compatibility, but they should not be used for new configs. + Notes: - If you use allowlists, add `web_search`/`web_fetch` or `group:web`. diff --git a/docs/ja-JP/start/wizard.md b/docs/ja-JP/start/wizard.md index 19f53125857..d7a9a77bb57 100644 --- a/docs/ja-JP/start/wizard.md +++ b/docs/ja-JP/start/wizard.md @@ -67,7 +67,7 @@ openclaw agents add -推奨:エージェントが `web_search` を使用できるように、Brave Search APIキーを設定してください(`web_fetch` はキーなしで動作します)。最も簡単な方法:`openclaw configure --section web` を実行すると `tools.web.search.apiKey` が保存されます。ドキュメント:[Webツール](/tools/web)。 +推奨:エージェントが `web_search` を使用できるように、Brave Search APIキーを設定してください(`web_fetch` はキーなしで動作します)。最も簡単な方法:`openclaw configure --section web` を実行すると `plugins.entries.brave.config.webSearch.apiKey` に保存されます。旧 `tools.web.search.apiKey` パスは互換用に引き続き読み込まれますが、新しい設定では使用しないでください。ドキュメント:[Webツール](/tools/web)。 ## 関連ドキュメント diff --git a/docs/perplexity.md b/docs/perplexity.md index b71f34d666b..3ad4c50c3f7 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -12,7 +12,7 @@ OpenClaw supports Perplexity Search API as a `web_search` provider. It returns structured results with `title`, `url`, and `snippet` fields. For compatibility, OpenClaw also supports legacy Perplexity Sonar/OpenRouter setups. -If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplexity.apiKey`, or set `tools.web.search.perplexity.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results. +If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `plugins.entries.perplexity.config.webSearch.apiKey`, or set `plugins.entries.perplexity.config.webSearch.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results. ## Getting a Perplexity API key @@ -22,12 +22,12 @@ If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplex ## OpenRouter compatibility -If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `tools.web.search.perplexity.apiKey`. +If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `plugins.entries.perplexity.config.webSearch.apiKey`. -Optional legacy controls: +Optional compatibility controls: -- `tools.web.search.perplexity.baseUrl` -- `tools.web.search.perplexity.model` +- `plugins.entries.perplexity.config.webSearch.baseUrl` +- `plugins.entries.perplexity.config.webSearch.model` ## Config examples @@ -35,13 +35,21 @@ Optional legacy controls: ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "pplx-...", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "perplexity", - perplexity: { - apiKey: "pplx-...", - }, }, }, }, @@ -52,15 +60,23 @@ Optional legacy controls: ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "", + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "perplexity", - perplexity: { - apiKey: "", - baseUrl: "https://openrouter.ai/api/v1", - model: "perplexity/sonar-pro", - }, }, }, }, @@ -70,7 +86,7 @@ Optional legacy controls: ## Where to set the key **Via config:** run `openclaw configure --section web`. It stores the key in -`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`. +`~/.openclaw/openclaw.json` under `plugins.entries.perplexity.config.webSearch.apiKey`. That field also accepts SecretRef objects. **Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` @@ -151,7 +167,7 @@ await web_search({ ## Notes - Perplexity Search API returns structured web search results (`title`, `url`, `snippet`) -- OpenRouter or explicit `baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility +- OpenRouter or explicit `plugins.entries.perplexity.config.webSearch.baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`) See [Web tools](/tools/web) for the full web_search configuration. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index bbb1d90de87..bfa08e4194b 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -79,11 +79,13 @@ See [Memory](/concepts/memory). `web_search` uses API keys and may incur usage charges depending on your provider: -- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` -- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` -- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` -- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` -- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` +- **Brave Search API**: `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey` +- **Gemini (Google Search)**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey` +- **Grok (xAI)**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` +- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey` +- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` + +Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface. **Brave Search free credit:** Each Brave plan includes \$5/month in renewing free credit. The Search plan costs \$5 per 1,000 requests, so the credit covers diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 9f73c7d0112..4af529c640f 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -32,11 +32,12 @@ Scope intent: - `messages.tts.elevenlabs.apiKey` - `messages.tts.openai.apiKey` - `tools.web.fetch.firecrawl.apiKey` -- `tools.web.search.apiKey` -- `tools.web.search.gemini.apiKey` -- `tools.web.search.grok.apiKey` -- `tools.web.search.kimi.apiKey` -- `tools.web.search.perplexity.apiKey` +- `plugins.entries.brave.config.webSearch.apiKey` +- `plugins.entries.google.config.webSearch.apiKey` +- `plugins.entries.xai.config.webSearch.apiKey` +- `plugins.entries.moonshot.config.webSearch.apiKey` +- `plugins.entries.perplexity.config.webSearch.apiKey` +- `plugins.entries.firecrawl.config.webSearch.apiKey` - `gateway.auth.password` - `gateway.auth.token` - `gateway.remote.token` @@ -108,6 +109,7 @@ Notes: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active. - In auto mode, non-selected provider refs are treated as inactive until selected. + - Legacy `tools.web.search.*` provider paths still resolve during the compatibility window, but the canonical SecretRef surface is `plugins.entries..config.webSearch.*`. ## Unsupported credentials diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index f72729dbadc..ff05f16e909 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -476,37 +476,44 @@ "optIn": true }, { - "id": "tools.web.search.apiKey", + "id": "plugins.entries.brave.config.webSearch.apiKey", "configFile": "openclaw.json", - "path": "tools.web.search.apiKey", + "path": "plugins.entries.brave.config.webSearch.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "tools.web.search.gemini.apiKey", + "id": "plugins.entries.google.config.webSearch.apiKey", "configFile": "openclaw.json", - "path": "tools.web.search.gemini.apiKey", + "path": "plugins.entries.google.config.webSearch.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "tools.web.search.grok.apiKey", + "id": "plugins.entries.xai.config.webSearch.apiKey", "configFile": "openclaw.json", - "path": "tools.web.search.grok.apiKey", + "path": "plugins.entries.xai.config.webSearch.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "tools.web.search.kimi.apiKey", + "id": "plugins.entries.moonshot.config.webSearch.apiKey", "configFile": "openclaw.json", - "path": "tools.web.search.kimi.apiKey", + "path": "plugins.entries.moonshot.config.webSearch.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "tools.web.search.perplexity.apiKey", + "id": "plugins.entries.perplexity.config.webSearch.apiKey", "configFile": "openclaw.json", - "path": "tools.web.search.perplexity.apiKey", + "path": "plugins.entries.perplexity.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.firecrawl.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.firecrawl.config.webSearch.apiKey", "secretShape": "secret_input", "optIn": true } diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index 901890dfb0a..eab0439311f 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -28,20 +28,22 @@ which helps with JS-heavy sites or pages that block plain HTTP fetches. ```json5 { - plugins: { - entries: { - firecrawl: { - enabled: true, - }, - }, - }, tools: { web: { search: { provider: "firecrawl", - firecrawl: { - apiKey: "FIRECRAWL_API_KEY_HERE", - baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + plugins: { + entries: { + firecrawl: { + enabled: true, + config: { + webSearch: { + apiKey: "FIRECRAWL_API_KEY_HERE", + baseUrl: "https://api.firecrawl.dev", + }, }, }, }, diff --git a/docs/tools/web.md b/docs/tools/web.md index 7cc67c07710..0e30c6c9c7c 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -43,12 +43,12 @@ See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexit The table above is alphabetical. If no `provider` is explicitly set, runtime auto-detection checks providers in this order: -1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config -2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config -3. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config -4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config -5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config -6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `tools.web.search.firecrawl.apiKey` config +1. **Brave** — `BRAVE_API_KEY` env var or `plugins.entries.brave.config.webSearch.apiKey` +2. **Gemini** — `GEMINI_API_KEY` env var or `plugins.entries.google.config.webSearch.apiKey` +3. **Grok** — `XAI_API_KEY` env var or `plugins.entries.xai.config.webSearch.apiKey` +4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `plugins.entries.moonshot.config.webSearch.apiKey` +5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` +6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `plugins.entries.firecrawl.config.webSearch.apiKey` If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -80,7 +80,10 @@ pricing. 2. Generate an API key in the dashboard 3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment. -For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `tools.web.search.perplexity.apiKey` with an `sk-or-...` key. Setting `tools.web.search.perplexity.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path. +For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `plugins.entries.perplexity.config.webSearch.apiKey` with an `sk-or-...` key. Setting `plugins.entries.perplexity.config.webSearch.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path. + +Provider-specific web search config now lives under `plugins.entries..config.webSearch.*`. +Legacy `tools.web.search.*` provider paths still load through a compatibility shim for one release, but they should not be used in new configs. See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details. @@ -88,12 +91,12 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks **Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path: -- Brave: `tools.web.search.apiKey` -- Firecrawl: `tools.web.search.firecrawl.apiKey` -- Gemini: `tools.web.search.gemini.apiKey` -- Grok: `tools.web.search.grok.apiKey` -- Kimi: `tools.web.search.kimi.apiKey` -- Perplexity: `tools.web.search.perplexity.apiKey` +- Brave: `plugins.entries.brave.config.webSearch.apiKey` +- Firecrawl: `plugins.entries.firecrawl.config.webSearch.apiKey` +- Gemini: `plugins.entries.google.config.webSearch.apiKey` +- Grok: `plugins.entries.xai.config.webSearch.apiKey` +- Kimi: `plugins.entries.moonshot.config.webSearch.apiKey` +- Perplexity: `plugins.entries.perplexity.config.webSearch.apiKey` All of these fields also support SecretRef objects. @@ -114,12 +117,22 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, provider: "brave", - apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret }, }, }, @@ -142,9 +155,18 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm search: { enabled: true, provider: "firecrawl", - firecrawl: { - apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set - baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + plugins: { + entries: { + firecrawl: { + enabled: true, + config: { + webSearch: { + apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set + baseUrl: "https://api.firecrawl.dev", + }, }, }, }, @@ -158,15 +180,23 @@ When you choose Firecrawl in onboarding or `openclaw configure --section web`, O ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret + mode: "llm-context", + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, provider: "brave", - apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret - brave: { - mode: "llm-context", - }, }, }, }, @@ -181,14 +211,22 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang` ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "pplx-...", // optional if PERPLEXITY_API_KEY is set + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, provider: "perplexity", - perplexity: { - apiKey: "pplx-...", // optional if PERPLEXITY_API_KEY is set - }, }, }, }, @@ -199,16 +237,24 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang` ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "", // optional if OPENROUTER_API_KEY is set + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, provider: "perplexity", - perplexity: { - apiKey: "", // optional if OPENROUTER_API_KEY is set - baseUrl: "https://openrouter.ai/api/v1", - model: "perplexity/sonar-pro", - }, }, }, }, @@ -224,22 +270,30 @@ which returns AI-synthesized answers backed by live Google Search results with c 1. Go to [Google AI Studio](https://aistudio.google.com/apikey) 2. Create an API key -3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `plugins.entries.google.config.webSearch.apiKey` ### Setting up Gemini search ```json5 { + plugins: { + entries: { + google: { + config: { + webSearch: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "gemini", - gemini: { - // API key (optional if GEMINI_API_KEY is set) - apiKey: "AIza...", - // Model (defaults to "gemini-2.5-flash") - model: "gemini-2.5-flash", - }, }, }, }, @@ -266,12 +320,12 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` - - **Firecrawl**: `FIRECRAWL_API_KEY` or `tools.web.search.firecrawl.apiKey` - - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` + - **Brave**: `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey` + - **Firecrawl**: `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` + - **Gemini**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey` + - **Grok**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` + - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey` + - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` - All provider key fields above support SecretRef objects. ### Config @@ -297,7 +351,7 @@ Search the web using your configured provider. Parameters depend on the selected provider. Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`. -If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors. +If you set `plugins.entries.perplexity.config.webSearch.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key under `plugins.entries.perplexity.config.webSearch.apiKey`, Search API-only filters return explicit errors. | Parameter | Description | | --------------------- | ----------------------------------------------------- | diff --git a/docs/zh-CN/brave-search.md b/docs/zh-CN/brave-search.md index d69d45f04d3..1f63d2441d0 100644 --- a/docs/zh-CN/brave-search.md +++ b/docs/zh-CN/brave-search.md @@ -27,11 +27,21 @@ OpenClaw 使用 Brave Search 作为 `web_search` 的默认提供商。 ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "BRAVE_API_KEY_HERE", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "brave", - apiKey: "BRAVE_API_KEY_HERE", maxResults: 5, timeoutSeconds: 30, }, @@ -40,6 +50,8 @@ OpenClaw 使用 Brave Search 作为 `web_search` 的默认提供商。 } ``` +Brave 的提供商专属搜索配置现在位于 `plugins.entries.brave.config.webSearch.*`。旧的 `tools.web.search.apiKey` 仅作为兼容层暂时保留。 + ## 注意事项 - Data for AI 套餐与 `web_search` **不**兼容。 diff --git a/docs/zh-CN/cli/config.md b/docs/zh-CN/cli/config.md index 06efbaec751..9c63985ab96 100644 --- a/docs/zh-CN/cli/config.md +++ b/docs/zh-CN/cli/config.md @@ -24,7 +24,7 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" -openclaw config unset tools.web.search.apiKey +openclaw config unset plugins.entries.brave.config.webSearch.apiKey ``` ## 路径 diff --git a/docs/zh-CN/gateway/configuration.md b/docs/zh-CN/gateway/configuration.md index 49c62eea4c9..86b0e50b4d7 100644 --- a/docs/zh-CN/gateway/configuration.md +++ b/docs/zh-CN/gateway/configuration.md @@ -53,7 +53,7 @@ OpenClaw 会从 `~/.openclaw/openclaw.json` 读取可选的 diff --git a/docs/zh-CN/help/faq.md b/docs/zh-CN/help/faq.md index 18b936e2cc8..b5531457484 100644 --- a/docs/zh-CN/help/faq.md +++ b/docs/zh-CN/help/faq.md @@ -1266,15 +1266,26 @@ Gateway 网关监视配置文件并支持热重载: ### 如何启用网络搜索(和网页抓取) -`web_fetch` 无需 API 密钥即可工作。`web_search` 需要 Brave Search API 密钥。**推荐:** 运行 `openclaw configure --section web` 将其存储在 `tools.web.search.apiKey` 中。环境变量替代方案:为 Gateway 网关进程设置 `BRAVE_API_KEY`。 +`web_fetch` 无需 API 密钥即可工作。`web_search` 需要所选提供商的 API 密钥。**推荐:** 运行 `openclaw configure --section web`。新的提供商专属配置会存储在 `plugins.entries..config.webSearch.*` 下。环境变量替代方案:为 Gateway 网关进程设置相应的提供商环境变量。 ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "BRAVE_API_KEY_HERE", + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, - apiKey: "BRAVE_API_KEY_HERE", + provider: "brave", maxResults: 5, }, fetch: { @@ -1290,6 +1301,7 @@ Gateway 网关监视配置文件并支持热重载: - 如果你使用允许列表,添加 `web_search`/`web_fetch` 或 `group:web`。 - `web_fetch` 默认启用(除非明确禁用)。 - 守护进程从 `~/.openclaw/.env`(或服务环境)读取环境变量。 +- 旧的 `tools.web.search.*` 提供商路径仍通过兼容层继续生效,但不应再用于新配置。 文档:[Web 工具](/tools/web)。 diff --git a/docs/zh-CN/perplexity.md b/docs/zh-CN/perplexity.md index 56a7505b302..ae9b4a05c72 100644 --- a/docs/zh-CN/perplexity.md +++ b/docs/zh-CN/perplexity.md @@ -34,15 +34,23 @@ OpenClaw 可以使用 Perplexity Sonar 作为 `web_search` 工具。你可以通 ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "pplx-...", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "perplexity", - perplexity: { - apiKey: "pplx-...", - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - }, }, }, }, @@ -53,21 +61,31 @@ OpenClaw 可以使用 Perplexity Sonar 作为 `web_search` 工具。你可以通 ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "pplx-...", + baseUrl: "https://api.perplexity.ai", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "perplexity", - perplexity: { - apiKey: "pplx-...", - baseUrl: "https://api.perplexity.ai", - }, }, }, }, } ``` -如果同时设置了 `PERPLEXITY_API_KEY` 和 `OPENROUTER_API_KEY`,请设置 `tools.web.search.perplexity.baseUrl`(或 `tools.web.search.perplexity.apiKey`)以消除歧义。 +如果同时设置了 `PERPLEXITY_API_KEY` 和 `OPENROUTER_API_KEY`,请设置 `plugins.entries.perplexity.config.webSearch.baseUrl`(或 `plugins.entries.perplexity.config.webSearch.apiKey`)以消除歧义。 + +提供商专属配置现在统一放在 `plugins.entries..config.webSearch.*`。旧的 `tools.web.search.*` 路径仅通过兼容层继续生效,不再推荐用于新配置。 如果未设置 base URL,OpenClaw 会根据 API 密钥来源选择默认值: diff --git a/docs/zh-CN/reference/api-usage-costs.md b/docs/zh-CN/reference/api-usage-costs.md index feb62d60c6d..91d4bf1160c 100644 --- a/docs/zh-CN/reference/api-usage-costs.md +++ b/docs/zh-CN/reference/api-usage-costs.md @@ -79,8 +79,13 @@ OpenClaw 可以从以下来源获取凭据: `web_search` 使用 API 密钥,可能产生使用费用: -- **Brave Search API**:`BRAVE_API_KEY` 或 `tools.web.search.apiKey` -- **Perplexity**(通过 OpenRouter):`PERPLEXITY_API_KEY` 或 `OPENROUTER_API_KEY` +- **Brave Search API**:`BRAVE_API_KEY` 或 `plugins.entries.brave.config.webSearch.apiKey` +- **Gemini**:`GEMINI_API_KEY` 或 `plugins.entries.google.config.webSearch.apiKey` +- **Grok**:`XAI_API_KEY` 或 `plugins.entries.xai.config.webSearch.apiKey` +- **Kimi**:`KIMI_API_KEY`、`MOONSHOT_API_KEY` 或 `plugins.entries.moonshot.config.webSearch.apiKey` +- **Perplexity**:`PERPLEXITY_API_KEY`、`OPENROUTER_API_KEY` 或 `plugins.entries.perplexity.config.webSearch.apiKey` + +旧的 `tools.web.search.*` 提供商路径仍会通过兼容层加载,但不再是推荐配置方式。 **Brave 免费套餐(额度充裕):** diff --git a/docs/zh-CN/tools/web.md b/docs/zh-CN/tools/web.md index 17c346dc64e..44026c67e29 100644 --- a/docs/zh-CN/tools/web.md +++ b/docs/zh-CN/tools/web.md @@ -18,7 +18,7 @@ x-i18n: OpenClaw 提供两个轻量级 Web 工具: -- `web_search` — 通过 Brave Search API(默认)或 Perplexity Sonar(直连或通过 OpenRouter)搜索网络。 +- `web_search` — 通过 Brave Search API、Firecrawl Search、Gemini with Google Search grounding、Grok、Kimi 或 Perplexity Search API 搜索网络。 - `web_fetch` — HTTP 获取 + 可读性提取(HTML → markdown/文本)。 这些**不是**浏览器自动化。对于 JS 密集型网站或需要登录的情况,请使用[浏览器工具](/tools/browser)。 @@ -26,18 +26,21 @@ OpenClaw 提供两个轻量级 Web 工具: ## 工作原理 - `web_search` 调用你配置的提供商并返回结果。 - - **Brave**(默认):返回结构化结果(标题、URL、摘要)。 - - **Perplexity**:返回带有实时网络搜索引用的 AI 综合答案。 - 结果按查询缓存 15 分钟(可配置)。 - `web_fetch` 执行普通 HTTP GET 并提取可读内容(HTML → markdown/文本)。它**不**执行 JavaScript。 - `web_fetch` 默认启用(除非显式禁用)。 +- 启用捆绑的 Firecrawl 插件后,还会提供 `firecrawl_search` 和 `firecrawl_scrape`。 ## 选择搜索提供商 -| 提供商 | 优点 | 缺点 | API 密钥 | -| ----------------- | ------------------------ | ---------------------------------- | -------------------------------------------- | -| **Brave**(默认) | 快速、结构化结果、免费层 | 传统搜索结果 | `BRAVE_API_KEY` | -| **Perplexity** | AI 综合答案、引用、实时 | 需要 Perplexity 或 OpenRouter 访问 | `OPENROUTER_API_KEY` 或 `PERPLEXITY_API_KEY` | +| 提供商 | 结果形式 | 说明 | API 密钥 | +| --------------------- | ------------------ | ----------------------------------------------- | ------------------------------------------- | +| **Brave Search API** | 结构化结果 + 摘要 | 支持 Brave `llm-context` 模式 | `BRAVE_API_KEY` | +| **Firecrawl Search** | 结构化结果 + 摘要 | Firecrawl 专用搜索控制请使用 `firecrawl_search` | `FIRECRAWL_API_KEY` | +| **Gemini** | AI 综合答案 + 引用 | 使用 Google Search grounding | `GEMINI_API_KEY` | +| **Grok** | AI 综合答案 + 引用 | 使用 xAI 实时网络搜索 | `XAI_API_KEY` | +| **Kimi** | AI 综合答案 + 引用 | 使用 Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| **Perplexity Search** | 结构化结果 + 摘要 | 兼容 OpenRouter Sonar 路径 | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | 参见 [Brave Search 设置](/brave-search) 和 [Perplexity Sonar](/perplexity) 了解提供商特定详情。 @@ -48,26 +51,34 @@ OpenClaw 提供两个轻量级 Web 工具: tools: { web: { search: { - provider: "brave", // 或 "perplexity" + provider: "brave", // 或 "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity" }, }, }, } ``` -示例:切换到 Perplexity Sonar(直连 API): +示例:切换到 Perplexity Search / Sonar 兼容路径: ```json5 { + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + apiKey: "pplx-...", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }, tools: { web: { search: { provider: "perplexity", - perplexity: { - apiKey: "pplx-...", - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - }, }, }, }, @@ -84,7 +95,7 @@ Brave 提供免费层和付费计划;查看 Brave API 门户了解当前限制 ### 在哪里设置密钥(推荐) -**推荐:** 运行 `openclaw configure --section web`。它将密钥存储在 `~/.openclaw/openclaw.json` 的 `tools.web.search.apiKey` 下。 +**推荐:** 运行 `openclaw configure --section web`。它会把密钥存储到 `~/.openclaw/openclaw.json` 的 `plugins.entries.brave.config.webSearch.apiKey`。 **环境变量替代方案:** 在 Gateway 网关进程环境中设置 `BRAVE_API_KEY`。对于 Gateway 网关安装,将其放在 `~/.openclaw/.env`(或你的服务环境)中。参见[环境变量](/help/faq#how-does-openclaw-load-environment-variables)。 @@ -107,13 +118,21 @@ Perplexity Sonar 模型具有内置的网络搜索功能,并返回带有引用 search: { enabled: true, provider: "perplexity", - perplexity: { - // API 密钥(如果设置了 OPENROUTER_API_KEY 或 PERPLEXITY_API_KEY 则可选) - apiKey: "sk-or-v1-...", - // 基础 URL(如果省略则根据密钥感知默认值) - baseUrl: "https://openrouter.ai/api/v1", - // 模型(默认为 perplexity/sonar-pro) - model: "perplexity/sonar-pro", + }, + }, + }, + plugins: { + entries: { + perplexity: { + config: { + webSearch: { + // API 密钥(如果设置了 OPENROUTER_API_KEY 或 PERPLEXITY_API_KEY 则可选) + apiKey: "sk-or-v1-...", + // 基础 URL(如果省略则根据密钥感知默认值) + baseUrl: "https://openrouter.ai/api/v1", + // 模型(默认为 perplexity/sonar-pro) + model: "perplexity/sonar-pro", + }, }, }, }, @@ -145,18 +164,28 @@ Perplexity Sonar 模型具有内置的网络搜索功能,并返回带有引用 - `tools.web.search.enabled` 不能为 `false`(默认:启用) - 所选提供商的 API 密钥: - - **Brave**:`BRAVE_API_KEY` 或 `tools.web.search.apiKey` - - **Perplexity**:`OPENROUTER_API_KEY`、`PERPLEXITY_API_KEY` 或 `tools.web.search.perplexity.apiKey` + - **Brave**:`BRAVE_API_KEY` 或 `plugins.entries.brave.config.webSearch.apiKey` + - **Perplexity**:`OPENROUTER_API_KEY`、`PERPLEXITY_API_KEY` 或 `plugins.entries.perplexity.config.webSearch.apiKey` ### 配置 ```json5 { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "BRAVE_API_KEY_HERE", + }, + }, + }, + }, + }, tools: { web: { search: { enabled: true, - apiKey: "BRAVE_API_KEY_HERE", // 如果设置了 BRAVE_API_KEY 则可选 maxResults: 5, timeoutSeconds: 30, cacheTtlMinutes: 15, @@ -166,6 +195,9 @@ Perplexity Sonar 模型具有内置的网络搜索功能,并返回带有引用 } ``` +提供商专属的 web_search 配置现在统一放在 `plugins.entries..config.webSearch.*`。 +旧的 `tools.web.search.*` 提供商路径仅作为兼容层暂时保留,不应再用于新配置。 + ### 工具参数 - `query`(必需) diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index 18500d81c14..565e1d6aac3 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -234,7 +234,7 @@ export async function runFirecrawlSearch( const apiKey = resolveFirecrawlApiKey(params.cfg); if (!apiKey) { throw new Error( - "web_search (firecrawl) needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.search.firecrawl.apiKey.", + "web_search (firecrawl) needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure plugins.entries.firecrawl.config.webSearch.apiKey.", ); } const count = diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 5728731d7ab..6202e18f4bd 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -238,7 +238,7 @@ export function createXaiWebSearchProvider() { return { error: "missing_xai_api_key", message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", docs: "https://docs.openclaw.ai/tools/web", }; } From 5464ad113e2c943066870840ab1a2775a6642c4e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:07:53 -0500 Subject: [PATCH 004/565] UI: expand-to-canvas, session navigation, plugin SDK fixes (#49483) * Plugins: fix signal SDK circular re-exports and reserved commands TDZ * UI: add expand-to-canvas button and in-app session navigation * changelog: UI expand/navigate and plugin TDZ/import fixes --- CHANGELOG.md | 3 ++ src/plugin-sdk/signal.ts | 12 +++-- src/plugins/commands.ts | 88 +++++++++++++++---------------- ui/src/styles/chat/grouped.css | 65 ++++++++++++++--------- ui/src/styles/components.css | 90 ++++++++++++++++++++++++++++++++ ui/src/ui/app-render.helpers.ts | 2 +- ui/src/ui/app-render.ts | 14 +++-- ui/src/ui/app-view-state.ts | 1 - ui/src/ui/chat/grouped-render.ts | 26 ++++++++- ui/src/ui/icons.ts | 7 +++ ui/src/ui/views/cron.ts | 26 +++++++-- ui/src/ui/views/sessions.ts | 46 +++++++++++++++- 12 files changed, 296 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6a4c7a34e..636956934fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,12 @@ Docs: https://docs.openclaw.ai - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. +- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. ### Fixes +- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. +- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index f44dfa2f9ff..2935f634b19 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -52,6 +52,12 @@ export { listSignalAccountIds, resolveDefaultSignalAccountId, } from "../../extensions/signal/api.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js"; -export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js"; -export { sendMessageSignal } from "../../extensions/signal/runtime-api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; +export { signalMessageActions } from "../../extensions/signal/src/message-actions.js"; +export { monitorSignalProvider } from "../../extensions/signal/src/monitor.js"; +export { probeSignal } from "../../extensions/signal/src/probe.js"; +export { + removeReactionSignal, + sendReactionSignal, +} from "../../extensions/signal/src/send-reactions.js"; +export { sendMessageSignal } from "../../extensions/signal/src/send.js"; diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index b16b3aef4ed..a44cbc26e7e 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -35,50 +35,15 @@ let registryLocked = false; const MAX_ARGS_LENGTH = 4096; /** - * Reserved command names that plugins cannot override. - * These are built-in commands from commands-registry.data.ts. + * Reserved command names that plugins cannot override (built-in commands). + * + * Constructed lazily inside validateCommandName to avoid TDZ errors: the + * bundler can place this module's body after call sites within the same + * output chunk, so any module-level const/let would be uninitialized when + * first accessed during plugin registration. */ -const RESERVED_COMMANDS = new Set([ - // Core commands - "help", - "commands", - "status", - "whoami", - "context", - "btw", - // Session management - "stop", - "restart", - "reset", - "new", - "compact", - // Configuration - "config", - "debug", - "allowlist", - "activation", - // Agent control - "skill", - "subagents", - "kill", - "steer", - "tell", - "model", - "models", - "queue", - // Messaging - "send", - // Execution - "bash", - "exec", - // Mode toggles - "think", - "verbose", - "reasoning", - "elevated", - // Billing - "usage", -]); +// eslint-disable-next-line no-var -- var avoids TDZ when bundler reorders module bodies in a chunk +var reservedCommands: Set | undefined; /** * Validate a command name. @@ -97,8 +62,41 @@ export function validateCommandName(name: string): string | null { return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; } - // Check reserved commands - if (RESERVED_COMMANDS.has(trimmed)) { + reservedCommands ??= new Set([ + "help", + "commands", + "status", + "whoami", + "context", + "btw", + "stop", + "restart", + "reset", + "new", + "compact", + "config", + "debug", + "allowlist", + "activation", + "skill", + "subagents", + "kill", + "steer", + "tell", + "model", + "models", + "queue", + "send", + "bash", + "exec", + "think", + "verbose", + "reasoning", + "elevated", + "usage", + ]); + + if (reservedCommands.has(trimmed)) { return `Command name "${trimmed}" is reserved by a built-in command`; } diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 16cf15d51ee..bce1c2422cf 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -194,10 +194,31 @@ img.chat-avatar { padding-right: 36px; } -.chat-copy-btn { +.chat-bubble-actions { position: absolute; top: 6px; right: 8px; + display: flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.chat-bubble:hover .chat-bubble-actions { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .chat-bubble-actions { + opacity: 1; + pointer-events: auto; + } +} + +.chat-copy-btn, +.chat-expand-btn { border: 1px solid var(--border); background: var(--bg); color: var(--muted); @@ -206,11 +227,7 @@ img.chat-avatar { font-size: 14px; line-height: 1; cursor: pointer; - opacity: 0; - pointer-events: none; - transition: - opacity 120ms ease-out, - background 120ms ease-out; + transition: background 120ms ease-out; } .chat-copy-btn__icon { @@ -250,12 +267,8 @@ img.chat-avatar { opacity: 1; } -.chat-bubble:hover .chat-copy-btn { - opacity: 1; - pointer-events: auto; -} - -.chat-copy-btn:hover { +.chat-copy-btn:hover, +.chat-expand-btn:hover { background: var(--bg-hover); } @@ -265,33 +278,37 @@ img.chat-avatar { } .chat-copy-btn[data-error="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--danger-subtle); background: var(--danger-subtle); color: var(--danger); } .chat-copy-btn[data-copied="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--ok-subtle); background: var(--ok-subtle); color: var(--ok); } -.chat-copy-btn:focus-visible { - opacity: 1; - pointer-events: auto; +.chat-copy-btn:focus-visible, +.chat-expand-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -@media (hover: none) { - .chat-copy-btn { - opacity: 1; - pointer-events: auto; - } +.chat-expand-btn__icon { + display: inline-flex; + width: 14px; + height: 14px; +} + +.chat-expand-btn__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; } /* Light mode: restore borders */ diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d4835d42aad..4edba864cd3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1312,6 +1312,96 @@ font: inherit; } +/* Code block wrapper chrome (generated by markdown renderer) */ + +.code-block-wrapper { + position: relative; + border-radius: 6px; + overflow: hidden; + margin-top: 0.75em; +} + +.code-block-wrapper pre { + margin: 0; + border-radius: 0 0 6px 6px; +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 8px 4px 12px; + background: rgba(0, 0, 0, 0.25); + font-size: 12px; + line-height: 1; +} + +:root[data-theme-mode="light"] .code-block-header { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-lang { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; + text-transform: lowercase; + user-select: none; +} + +.code-block-copy { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + font-size: 11px; + font-family: var(--font-body); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: + color 150ms ease, + background 150ms ease; +} + +.code-block-copy:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.1); +} + +:root[data-theme-mode="light"] .code-block-copy:hover { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-copy__done { + display: none; +} + +.code-block-copy.copied .code-block-copy__idle { + display: none; +} + +.code-block-copy.copied .code-block-copy__done { + display: inline; + color: var(--success, #22c55e); +} + +.json-collapse { + margin-top: 0.75em; +} + +.json-collapse summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + padding: 4px 8px; + user-select: none; +} + +.json-collapse summary:hover { + color: var(--text); +} + /* =========================================== Lists =========================================== */ diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index e83825ab899..d27fb221582 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -487,7 +487,7 @@ export function renderChatMobileToggle(state: AppViewState) { `; } -function switchChatSession(state: AppViewState, nextSessionKey: string) { +export function switchChatSession(state: AppViewState, nextSessionKey: string) { state.sessionKey = nextSessionKey; state.chatMessage = ""; state.chatStream = null; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index dd9ac932a2e..c0535cd6c30 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -14,6 +14,7 @@ import { renderTab, renderSidebarConnectionStatus, renderTopbarThemeModeToggle, + switchChatSession, } from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; @@ -765,6 +766,10 @@ export function renderApp(state: AppViewState) { onRefresh: () => loadSessions(state), onPatch: (key, patch) => patchSession(state, key, patch), onDelete: (key) => deleteSessionAndRefresh(state, key), + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -865,6 +870,10 @@ export function renderApp(state: AppViewState) { } await loadCronRuns(state, state.cronRunsJobId); }, + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -1432,10 +1441,7 @@ export function renderApp(state: AppViewState) { state.setTab("agents" as import("./navigation.ts").Tab); }, onSessionSelect: (key: string) => { - state.setSessionKey(key); - state.chatMessages = []; - void loadChatHistory(state); - void state.loadAssistantIdentity(); + switchChatSession(state, key); }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4e9742fbdbc..df806794645 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -357,7 +357,6 @@ export type AppViewState = { handleDebugCall: () => Promise; handleRunUpdate: () => Promise; setPassword: (next: string) => void; - setSessionKey: (next: string) => void; setChatMessage: (next: string) => void; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; handleAbortChat: () => Promise; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7dcc0b62e19..d5cc37f4fe9 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -620,6 +620,20 @@ function jsonSummaryLabel(parsed: unknown): string { return "JSON"; } +function renderExpandButton(markdown: string, onOpenSidebar: (content: string) => void) { + return html` + + `; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean }, @@ -647,6 +661,7 @@ function renderGroupedMessage( const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null; const markdown = markdownBase; const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim()); // Detect pure-JSON messages and render as collapsible block const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; @@ -674,9 +689,18 @@ function renderGroupedMessage( const toolPreview = markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : ""; + const hasActions = canCopyMarkdown || canExpand; + return html`
- ${canCopyMarkdown ? html`
${renderCopyAsMarkdownButton(markdown!)}
` : nothing} + ${ + hasActions + ? html`
+ ${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing} + ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} +
` + : nothing + } ${ isToolMessage ? html` diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index de594541110..39815b8aa0b 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -441,6 +441,13 @@ export const icons = { `, + panelRightOpen: html` + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 1509637b46f..e87879d0321 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -91,6 +91,7 @@ export type CronProps = { cronRunsQuery?: string; cronRunsSortDir?: CronSortDir; }) => void | Promise; + onNavigateToChat?: (sessionKey: string) => void; }; function getRunStatusOptions(): Array<{ value: CronRunsStatusValue; label: string }> { @@ -674,7 +675,7 @@ export function renderCron(props: CronProps) { ` : html`
- ${runs.map((entry) => renderRun(entry, props.basePath))} + ${runs.map((entry) => renderRun(entry, props.basePath, props.onNavigateToChat))}
` } @@ -1709,7 +1710,11 @@ function runDeliveryLabel(value: string): string { } } -function renderRun(entry: CronRunLogEntry, basePath: string) { +function renderRun( + entry: CronRunLogEntry, + basePath: string, + onNavigateToChat?: (sessionKey: string) => void, +) { const chatUrl = typeof entry.sessionKey === "string" && entry.sessionKey.trim().length > 0 ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(entry.sessionKey)}` @@ -1749,7 +1754,22 @@ function renderRun(entry: CronRunLogEntry, basePath: string) { } ${ chatUrl - ? html`` + ? html`` : nothing } ${entry.error ? html`
${entry.error}
` : nothing} diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 2620ec35acf..e028b7b4c85 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -43,6 +43,7 @@ export type SessionsProps = { }, ) => void; onDelete: (key: string) => void; + onNavigateToChat?: (sessionKey: string) => void; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -337,6 +338,7 @@ export function renderSessions(props: SessionsProps) { props.onActionsOpenChange, props.actionsOpenKey, props.loading, + props.onNavigateToChat, ), ) } @@ -391,6 +393,7 @@ function renderRow( onActionsOpenChange: (key: string | null) => void, actionsOpenKey: string | null, disabled: boolean, + onNavigateToChat?: (sessionKey: string) => void, ) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; const rawThinking = row.thinkingLevel ?? ""; @@ -430,7 +433,30 @@ function renderRow(
- ${canLink ? html`${row.key}` : row.key} + ${ + canLink + ? html` { + if ( + e.defaultPrevented || + e.button !== 0 || + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey + ) { + return; + } + if (onNavigateToChat) { + e.preventDefault(); + onNavigateToChat(row.key); + } + }} + >${row.key}` + : row.key + } ${ showDisplayName ? html`${displayName}` @@ -548,7 +574,23 @@ function renderRow( onActionsOpenChange(null)} + @click=${(e: MouseEvent) => { + onActionsOpenChange(null); + if ( + e.defaultPrevented || + e.button !== 0 || + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey + ) { + return; + } + if (onNavigateToChat) { + e.preventDefault(); + onNavigateToChat(row.key); + } + }} > Open in Chat From bfecc58a620a02973199334f429fe5ce60881873 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Tue, 17 Mar 2026 22:08:19 -0700 Subject: [PATCH 005/565] xAI: add web search credential metadata (#49472) Merged via squash. Prepared head SHA: faefa4089d0fdf153961d4dbf6feda58d6b6a29a Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- CHANGELOG.md | 1 + extensions/imessage/runtime-api.ts | 5 ++++- extensions/imessage/src/channel.runtime.ts | 5 +---- extensions/imessage/src/channel.ts | 6 +++--- extensions/moonshot/index.ts | 2 +- extensions/xai/web-search.ts | 2 ++ src/agents/tools/web-search.test.ts | 6 +----- 7 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636956934fb..1a4a3b156ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,7 @@ Docs: https://docs.openclaw.ai - Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc. - Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant. - Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc. +- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob. ### Breaking diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index 57eb1ed64aa..6cd9966f193 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -18,7 +18,10 @@ export { normalizeIMessageMessagingTarget, } from "../../src/channels/plugins/normalize/imessage.js"; export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js"; -export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js"; +export { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "./src/group-policy.js"; export { monitorIMessageProvider } from "./src/monitor.js"; export type { MonitorIMessageOpts } from "./src/monitor.js"; diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index 4df9d5651d5..32cd39a1d64 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -1,8 +1,5 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { - PAIRING_APPROVED_MESSAGE, - resolveChannelMediaMaxBytes, -} from "../runtime-api.js"; +import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js"; import type { ResolvedIMessageAccount } from "./accounts.js"; import { monitorIMessageProvider } from "./monitor.js"; import { probeIMessage } from "./probe.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 22faf226e89..27a26a9db88 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,6 +1,9 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, @@ -8,9 +11,6 @@ import { normalizeIMessageMessagingTarget, type ChannelPlugin, } from "../runtime-api.js"; -import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { type RoutePeer } from "openclaw/plugin-sdk/routing"; -import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { resolveIMessageGroupRequireMention, diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 3a8ed52c805..241d53e6014 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,6 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; -import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 6202e18f4bd..ec69073e359 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -210,6 +210,8 @@ export function createXaiWebSearchProvider() { signupUrl: "https://console.x.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 30, + credentialPath: "tools.web.search.grok.apiKey", + inactiveSecretPaths: ["tools.web.search.grok.apiKey"], getCredentialValue: (searchConfig?: Record) => getScopedCredentialValue(searchConfig, "grok"), setCredentialValue: (searchConfigTarget: Record, value: unknown) => diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index cb9cabfe87f..8edaca15b94 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -27,7 +27,6 @@ const { resolveKimiApiKey, resolveKimiModel, resolveKimiBaseUrl, extractKimiCita moonshotTesting; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); -const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_"); const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_"); const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-"); @@ -231,10 +230,7 @@ describe("web_search kimi config resolution", () => { it("extracts citations from search_results", () => { expect( extractKimiCitations({ - search_results: [ - { url: "https://example.com/one" }, - { url: "https://example.com/two" }, - ], + search_results: [{ url: "https://example.com/one" }, { url: "https://example.com/two" }], }), ).toEqual(["https://example.com/one", "https://example.com/two"]); }); From 4c160d2c3ad98a63531a644712afff69471e71bd Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Tue, 17 Mar 2026 22:12:37 -0700 Subject: [PATCH 006/565] Signal: fix account config type import (#49470) Merged via squash. Prepared head SHA: fab2ef4c1f8fa4922bcf76fc34f04ad5786c56e4 Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- CHANGELOG.md | 1 + extensions/signal/runtime-api.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4a3b156ba..23f05fcaac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,7 @@ Docs: https://docs.openclaw.ai - Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant. - Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc. - xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob. +- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob. ### Breaking diff --git a/extensions/signal/runtime-api.ts b/extensions/signal/runtime-api.ts index e258df15c9c..52ebf7ff363 100644 --- a/extensions/signal/runtime-api.ts +++ b/extensions/signal/runtime-api.ts @@ -1 +1,2 @@ export * from "./src/index.js"; +export type { SignalAccountConfig } from "../../src/config/types.signal.js"; From 4ca87fa4b0c606eee5b247bd3f7525f23c3ea17b Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 22:14:56 -0700 Subject: [PATCH 007/565] fix: restore main build (#49478) * Build: restore main build * Config: align model compat schema --- extensions/signal/src/accounts.ts | 2 +- extensions/xai/web-search.ts | 7 ++++--- src/config/types.models.ts | 27 ++++++++++++++++----------- src/config/zod-schema.core.ts | 23 ++++++++++++++++++++++- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index f167654ef3f..2cc323dd33d 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -4,7 +4,7 @@ import { resolveAccountEntry, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import type { SignalAccountConfig } from "../runtime-api.js"; +import type { SignalAccountConfig } from "../../../src/config/types.signal.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index ec69073e359..d1e3a03eb82 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -14,6 +14,7 @@ import { wrapWebContent, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; +import type { WebSearchProviderPlugin } from "../../src/plugins/types.js"; const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; @@ -200,7 +201,7 @@ async function runXaiWebSearch(params: { return payload; } -export function createXaiWebSearchProvider() { +export function createXaiWebSearchProvider(): WebSearchProviderPlugin { return { id: "grok", label: "Grok (xAI)", @@ -210,8 +211,8 @@ export function createXaiWebSearchProvider() { signupUrl: "https://console.x.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 30, - credentialPath: "tools.web.search.grok.apiKey", - inactiveSecretPaths: ["tools.web.search.grok.apiKey"], + credentialPath: "plugins.entries.xai.config.webSearch.apiKey", + inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], getCredentialValue: (searchConfig?: Record) => getScopedCredentialValue(searchConfig, "grok"), setCredentialValue: (searchConfigTarget: Record, value: unknown) => diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 3c8c5debd6a..bc79f24943f 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -1,3 +1,4 @@ +import type { OpenAICompletionsCompat } from "@mariozechner/pi-ai"; import type { SecretInput } from "./types.secrets.js"; export const MODEL_APIS = [ @@ -13,21 +14,25 @@ export const MODEL_APIS = [ export type ModelApi = (typeof MODEL_APIS)[number]; -export type ModelCompatConfig = { - supportsStore?: boolean; - supportsDeveloperRole?: boolean; - supportsReasoningEffort?: boolean; - supportsUsageInStreaming?: boolean; +type SupportedOpenAICompatFields = Pick< + OpenAICompletionsCompat, + | "supportsStore" + | "supportsDeveloperRole" + | "supportsReasoningEffort" + | "supportsUsageInStreaming" + | "supportsStrictMode" + | "maxTokensField" + | "thinkingFormat" + | "requiresToolResultName" + | "requiresAssistantAfterToolResult" + | "requiresThinkingAsText" +>; + +export type ModelCompatConfig = SupportedOpenAICompatFields & { supportsTools?: boolean; - supportsStrictMode?: boolean; toolSchemaProfile?: "xai"; nativeWebSearchTool?: boolean; toolCallArgumentsEncoding?: "html-entities"; - maxTokensField?: "max_completion_tokens" | "max_tokens"; - thinkingFormat?: "openai" | "zai" | "qwen"; - requiresToolResultName?: boolean; - requiresAssistantAfterToolResult?: boolean; - requiresThinkingAsText?: boolean; requiresMistralToolIds?: boolean; requiresOpenAiAnthropicToolPayload?: boolean; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 199637bba52..22c589c8490 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -6,6 +6,7 @@ import { isValidExecSecretRefId, isValidFileSecretRefId, } from "../secrets/ref-contract.js"; +import type { ModelCompatConfig } from "./types.models.js"; import { MODEL_APIS } from "./types.models.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -191,16 +192,36 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), - thinkingFormat: z.union([z.literal("openai"), z.literal("zai"), z.literal("qwen")]).optional(), + thinkingFormat: z + .union([ + z.literal("openai"), + z.literal("zai"), + z.literal("qwen"), + z.literal("qwen-chat-template"), + ]) + .optional(), requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), + toolSchemaProfile: z.literal("xai").optional(), + nativeWebSearchTool: z.boolean().optional(), + toolCallArgumentsEncoding: z.literal("html-entities").optional(), requiresMistralToolIds: z.boolean().optional(), requiresOpenAiAnthropicToolPayload: z.boolean().optional(), }) .strict() .optional(); +type AssertAssignable<_T extends U, U> = true; +type _ModelCompatSchemaAssignableToType = AssertAssignable< + z.infer, + ModelCompatConfig | undefined +>; +type _ModelCompatTypeAssignableToSchema = AssertAssignable< + ModelCompatConfig | undefined, + z.infer +>; + export const ModelDefinitionSchema = z .object({ id: z.string().min(1), From 2c579b6ac16a55c5415b4436160ebc3eec1e16cb Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Wed, 18 Mar 2026 13:20:06 +0800 Subject: [PATCH 008/565] fix(models): preserve @YYYYMMDD version suffixes (#48896) thanks @Alix-007 Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> Co-authored-by: frankekn --- CHANGELOG.md | 4 + src/agents/model-ref-profile.test.ts | 13 + src/agents/model-ref-profile.ts | 11 +- .../reply/directive-handling.model.test.ts | 374 +++++++++++++++++- .../reply/directive-handling.model.ts | 72 +++- .../reply/directive-handling.persist.ts | 71 ++-- src/auto-reply/reply/session.test.ts | 57 +++ src/auto-reply/reply/session.ts | 14 + 8 files changed, 566 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f05fcaac0..115481dd284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,10 @@ Docs: https://docs.openclaw.ai - Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. +- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007. + +### Fixes + - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. diff --git a/src/agents/model-ref-profile.test.ts b/src/agents/model-ref-profile.test.ts index 92c2211eff7..f85c7d37675 100644 --- a/src/agents/model-ref-profile.test.ts +++ b/src/agents/model-ref-profile.test.ts @@ -53,4 +53,17 @@ describe("splitTrailingAuthProfile", () => { profile: "google-gemini-cli:test@gmail.com", }); }); + + it("keeps @YYYYMMDD version suffixes in model ids", () => { + expect(splitTrailingAuthProfile("custom/vertex-ai_claude-haiku-4-5@20251001")).toEqual({ + model: "custom/vertex-ai_claude-haiku-4-5@20251001", + }); + }); + + it("supports auth profiles after @YYYYMMDD version suffixes", () => { + expect(splitTrailingAuthProfile("custom/vertex-ai_claude-haiku-4-5@20251001@work")).toEqual({ + model: "custom/vertex-ai_claude-haiku-4-5@20251001", + profile: "work", + }); + }); }); diff --git a/src/agents/model-ref-profile.ts b/src/agents/model-ref-profile.ts index 54ec79f905f..7437a554590 100644 --- a/src/agents/model-ref-profile.ts +++ b/src/agents/model-ref-profile.ts @@ -8,11 +8,20 @@ export function splitTrailingAuthProfile(raw: string): { } const lastSlash = trimmed.lastIndexOf("/"); - const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); + let profileDelimiter = trimmed.indexOf("@", lastSlash + 1); if (profileDelimiter <= 0) { return { model: trimmed }; } + const versionSuffix = trimmed.slice(profileDelimiter + 1); + if (/^\d{8}(?:@|$)/.test(versionSuffix)) { + const nextDelimiter = trimmed.indexOf("@", profileDelimiter + 9); + if (nextDelimiter < 0) { + return { model: trimmed }; + } + profileDelimiter = nextDelimiter; + } + const model = trimmed.slice(0, profileDelimiter).trim(); const profile = trimmed.slice(profileDelimiter + 1).trim(); if (!model || !profile) { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index b815ecfc9b9..f80ebecfc91 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "../../agents/auth-profiles.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -8,6 +12,7 @@ import { maybeHandleModelDirectiveInfo, resolveModelSelectionFromDirective, } from "./directive-handling.model.js"; +import { persistInlineDirectives } from "./directive-handling.persist.js"; // Mock dependencies for directive handling persistence. vi.mock("../../agents/agent-scope.js", () => ({ @@ -28,6 +33,8 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); +const TEST_AGENT_DIR = "/tmp/agent"; + function baseAliasIndex(): ModelAliasIndex { return { byAlias: new Map(), byKey: new Map() }; } @@ -39,6 +46,31 @@ function baseConfig(): OpenClawConfig { } as unknown as OpenClawConfig; } +beforeEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: TEST_AGENT_DIR, + store: { version: 1, profiles: {} }, + }, + ]); +}); + +afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); +}); + +function setAuthProfiles( + profiles: Record, +) { + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: TEST_AGENT_DIR, + store: { version: 1, profiles }, + }, + ]); +} + function resolveModelSelectionForCommand(params: { command: string; allowedModelKeys: Set; @@ -47,7 +79,7 @@ function resolveModelSelectionForCommand(params: { return resolveModelSelectionFromDirective({ directives: parseInlineDirectives(params.command), cfg: { commands: { text: true } } as unknown as OpenClawConfig, - agentDir: "/tmp/agent", + agentDir: TEST_AGENT_DIR, defaultProvider: "anthropic", defaultModel: "claude-opus-4-5", aliasIndex: baseAliasIndex(), @@ -63,7 +95,7 @@ async function resolveModelInfoReply( return maybeHandleModelDirectiveInfo({ directives: parseInlineDirectives("/model"), cfg: baseConfig(), - agentDir: "/tmp/agent", + agentDir: TEST_AGENT_DIR, activeAgentId: "main", provider: "anthropic", model: "claude-opus-4-5", @@ -181,6 +213,342 @@ describe("/model chat UX", () => { isDefault: false, }); }); + + it("treats @YYYYMMDD as a profile override when that profile exists for the resolved provider", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/gpt-4o@20251001", + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("supports alias selections with numeric auth-profile overrides", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const aliasIndex: ModelAliasIndex = { + byAlias: new Map([["gpt", { alias: "gpt", ref: { provider: "openai", model: "gpt-4o" } }]]), + byKey: new Map([["openai/gpt-4o", ["gpt"]]]), + }; + + const resolved = resolveModelSelectionFromDirective({ + directives: parseInlineDirectives("/model gpt@20251001"), + cfg: { commands: { text: true } } as unknown as OpenClawConfig, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex, + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + provider: "anthropic", + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + alias: "gpt", + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("supports providerless allowlist selections with numeric auth-profile overrides", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model gpt-4o@20251001", + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("keeps @YYYYMMDD as part of the model when the stored numeric profile is for another provider", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "anthropic", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model custom/vertex-ai_claude-haiku-4-5@20251001", + allowedModelKeys: new Set(["custom/vertex-ai_claude-haiku-4-5@20251001"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "custom", + model: "vertex-ai_claude-haiku-4-5@20251001", + isDefault: false, + }); + expect(resolved.profileOverride).toBeUndefined(); + }); + + it("persists inferred numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model openai/gpt-4o@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o", "openai/gpt-4o@20251001"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists alias-based numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const aliasIndex: ModelAliasIndex = { + byAlias: new Map([["gpt", { alias: "gpt", ref: { provider: "openai", model: "gpt-4o" } }]]), + byKey: new Map([["openai/gpt-4o", ["gpt"]]]), + }; + const directives = parseInlineDirectives("/model gpt@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex, + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists providerless numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model gpt-4o@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists explicit auth profiles after @YYYYMMDD version suffixes in mixed-content messages", async () => { + setAuthProfiles({ + work: { + type: "api_key", + provider: "custom", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives( + "/model custom/vertex-ai_claude-haiku-4-5@20251001@work hello", + ); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["custom/vertex-ai_claude-haiku-4-5@20251001"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("custom"); + expect(sessionEntry.modelOverride).toBe("vertex-ai_claude-haiku-4-5@20251001"); + expect(sessionEntry.authProfileOverride).toBe("work"); + }); + + it("ignores invalid mixed-content model directives during persistence", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model 99 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4o", + authProfileOverride: "20251001", + authProfileOverrideSource: "user", + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + const persisted = await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "openai", + model: "gpt-4o", + initialModelLabel: "openai/gpt-4o", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(persisted.provider).toBe("openai"); + expect(persisted.model).toBe("gpt-4o"); + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + expect(sessionEntry.authProfileOverrideSource).toBe("user"); + }); }); describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 521d3bd6fea..986f632bcb5 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,8 +1,12 @@ -import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; +import { + ensureAuthProfileStore, + resolveAuthStorePathForDisplay, +} from "../../agents/auth-profiles.js"; import { type ModelAliasIndex, modelKey, normalizeProviderId, + normalizeProviderIdForAuth, resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; @@ -353,6 +357,39 @@ export async function maybeHandleModelDirectiveInfo(params: { return { text: lines.join("\n") }; } +function resolveStoredNumericProfileModelDirective(params: { raw: string; agentDir: string }): { + modelRaw: string; + profileId: string; + profileProvider: string; +} | null { + const trimmed = params.raw.trim(); + const lastSlash = trimmed.lastIndexOf("/"); + const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); + if (profileDelimiter <= 0) { + return null; + } + + const profileId = trimmed.slice(profileDelimiter + 1).trim(); + if (!/^\d{8}$/.test(profileId)) { + return null; + } + + const modelRaw = trimmed.slice(0, profileDelimiter).trim(); + if (!modelRaw) { + return null; + } + + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profile = store.profiles[profileId]; + if (!profile) { + return null; + } + + return { modelRaw, profileId, profileProvider: profile.provider }; +} + export function resolveModelSelectionFromDirective(params: { directives: InlineDirectives; cfg: OpenClawConfig; @@ -376,6 +413,28 @@ export function resolveModelSelectionFromDirective(params: { } const raw = params.directives.rawModelDirective.trim(); + const storedNumericProfile = + params.directives.rawModelProfile === undefined + ? resolveStoredNumericProfileModelDirective({ + raw, + agentDir: params.agentDir, + }) + : null; + const storedNumericProfileSelection = storedNumericProfile + ? resolveModelDirectiveSelection({ + raw: storedNumericProfile.modelRaw, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, + }) + : null; + const useStoredNumericProfile = + Boolean(storedNumericProfileSelection?.selection) && + normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") === + normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? ""); + const modelRaw = + useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw; let modelSelection: ModelDirectiveSelection | undefined; if (/^[0-9]+$/.test(raw)) { @@ -390,7 +449,7 @@ export function resolveModelSelectionFromDirective(params: { } const explicit = resolveModelRefFromString({ - raw, + raw: modelRaw, defaultProvider: params.defaultProvider, aliasIndex: params.aliasIndex, }); @@ -410,7 +469,7 @@ export function resolveModelSelectionFromDirective(params: { if (!modelSelection) { const resolved = resolveModelDirectiveSelection({ - raw, + raw: modelRaw, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, @@ -427,9 +486,12 @@ export function resolveModelSelectionFromDirective(params: { } let profileOverride: string | undefined; - if (modelSelection && params.directives.rawModelProfile) { + const rawProfile = + params.directives.rawModelProfile ?? + (useStoredNumericProfile ? storedNumericProfile?.profileId : undefined); + if (modelSelection && rawProfile) { const profileResolved = resolveProfileOverride({ - rawProfile: params.directives.rawModelProfile, + rawProfile, provider: modelSelection.provider, cfg: params.cfg, agentDir: params.agentDir, diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index f4087055801..ddb308ae6d7 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -8,16 +8,14 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { buildModelAliasIndex, type ModelAliasIndex, - modelKey, resolveDefaultModelForAgent, - resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; -import { resolveProfileOverride } from "./directive-handling.auth.js"; +import { resolveModelSelectionFromDirective } from "./directive-handling.model.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { enqueueModeSwitchEvents } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; @@ -64,7 +62,7 @@ export async function persistInlineDirectives(params: { const activeAgentId = sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : resolveDefaultAgentId(cfg); - const agentDir = resolveAgentDir(cfg, activeAgentId); + const agentDir = params.agentDir ?? resolveAgentDir(cfg, activeAgentId); if (sessionEntry && sessionStore && sessionKey) { const prevElevatedLevel = @@ -139,49 +137,40 @@ export async function persistInlineDirectives(params: { ? params.effectiveModelDirective : undefined; if (modelDirective) { - const resolved = resolveModelRefFromString({ - raw: modelDirective, + const modelResolution = resolveModelSelectionFromDirective({ + directives: { + ...directives, + hasModelDirective: true, + rawModelDirective: modelDirective, + }, + cfg, + agentDir, defaultProvider, + defaultModel, aliasIndex, + allowedModelKeys, + allowedModelCatalog: [], + provider, }); - if (resolved) { - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - let profileOverride: string | undefined; - if (directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: resolved.ref.provider, - cfg, - agentDir, - }); - if (profileResolved.error) { - throw new Error(profileResolved.error); - } - profileOverride = profileResolved.profileId; - } - const isDefault = - resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel; - const { updated: modelUpdated } = applyModelOverrideToSessionEntry({ - entry: sessionEntry, - selection: { - provider: resolved.ref.provider, - model: resolved.ref.model, - isDefault, - }, - profileOverride, - }); - provider = resolved.ref.provider; - model = resolved.ref.model; - const nextLabel = `${provider}/${model}`; - if (nextLabel !== initialModelLabel) { - enqueueSystemEvent(formatModelSwitchEvent(nextLabel, resolved.alias), { + if (modelResolution.modelSelection) { + const { updated: modelUpdated } = applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: modelResolution.modelSelection, + profileOverride: modelResolution.profileOverride, + }); + provider = modelResolution.modelSelection.provider; + model = modelResolution.modelSelection.model; + const nextLabel = `${provider}/${model}`; + if (nextLabel !== initialModelLabel) { + enqueueSystemEvent( + formatModelSwitchEvent(nextLabel, modelResolution.modelSelection.alias), + { sessionKey, contextKey: `model:${nextLabel}`, - }); - } - updated = updated || modelUpdated; + }, + ); } + updated = updated || modelUpdated; } } if (directives.hasQueueDirective && directives.queueReset) { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 5c382a74aa9..fb43946a6b4 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1428,6 +1428,63 @@ describe("initSessionState preserves behavior overrides across /new and /reset", } }); + it("preserves selected auth profile overrides across /new and /reset", async () => { + const storePath = await createStorePath("openclaw-reset-model-auth-"); + const sessionKey = "agent:main:telegram:dm:user-model-auth"; + const existingSessionId = "existing-session-model-auth"; + const overrides = { + providerOverride: "openai", + modelOverride: "gpt-4o", + authProfileOverride: "20251001", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + } as const; + const cases = [ + { + name: "new preserves selected auth profile overrides", + body: "/new", + }, + { + name: "reset preserves selected auth profile overrides", + body: "/reset", + }, + ] as const; + + for (const testCase of cases) { + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { ...overrides }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: testCase.body, + RawBody: testCase.body, + CommandBody: testCase.body, + From: "user-model-auth", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession, testCase.name).toBe(true); + expect(result.resetTriggered, testCase.name).toBe(true); + expect(result.sessionId, testCase.name).not.toBe(existingSessionId); + expect(result.sessionEntry, testCase.name).toMatchObject(overrides); + } + }); + it("archives the old session store entry on /new", async () => { const storePath = await createStorePath("openclaw-archive-old-"); const sessionKey = "agent:main:telegram:dm:user-archive"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a2c0b1c7cf4..f6f5d3bfdfa 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -217,6 +217,9 @@ export async function initSessionState(params: { let persistedTtsAuto: TtsAutoMode | undefined; let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; + let persistedAuthProfileOverride: string | undefined; + let persistedAuthProfileOverrideSource: SessionEntry["authProfileOverrideSource"]; + let persistedAuthProfileOverrideCompactionCount: number | undefined; let persistedLabel: string | undefined; const normalizedChatType = normalizeChatType(ctx.ChatType); @@ -353,6 +356,9 @@ export async function initSessionState(params: { persistedTtsAuto = entry.ttsAuto; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; + persistedAuthProfileOverride = entry.authProfileOverride; + persistedAuthProfileOverrideSource = entry.authProfileOverrideSource; + persistedAuthProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; persistedLabel = entry.label; } else { sessionId = crypto.randomUUID(); @@ -369,6 +375,9 @@ export async function initSessionState(params: { persistedTtsAuto = entry.ttsAuto; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; + persistedAuthProfileOverride = entry.authProfileOverride; + persistedAuthProfileOverrideSource = entry.authProfileOverrideSource; + persistedAuthProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; persistedLabel = entry.label; } } @@ -420,6 +429,11 @@ export async function initSessionState(params: { responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, + authProfileOverride: persistedAuthProfileOverride ?? baseEntry?.authProfileOverride, + authProfileOverrideSource: + persistedAuthProfileOverrideSource ?? baseEntry?.authProfileOverrideSource, + authProfileOverrideCompactionCount: + persistedAuthProfileOverrideCompactionCount ?? baseEntry?.authProfileOverrideCompactionCount, label: persistedLabel ?? baseEntry?.label, sendPolicy: baseEntry?.sendPolicy, queueMode: baseEntry?.queueMode, From 5f89897df1a9562dcb03795223b407e665ef3f11 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:20:14 -0500 Subject: [PATCH 009/565] plugins: dist node_modules symlink + config raw-toggle UI fix (#49490) * plugins: symlink node_modules into dist plugin dir for bare-specifier resolution * UI: fix config raw-toggle button sizing and semantic markup * Update scripts/stage-bundled-plugin-runtime.mjs Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update ui/src/styles/config.css Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: hoist dist node_modules cleanup before existsSync guard; drop !important from config toggle --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- scripts/stage-bundled-plugin-runtime.mjs | 20 +++++++++++++++++++ .../stage-bundled-plugin-runtime.test.ts | 6 ++++++ ui/src/styles/config.css | 7 +++++++ ui/src/ui/views/config.ts | 7 +++---- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index d6585d3191a..07fd9e958f0 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -88,10 +88,29 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) { function linkPluginNodeModules(params) { const runtimeNodeModulesDir = path.join(params.runtimePluginDir, "node_modules"); removePathIfExists(runtimeNodeModulesDir); + if (params.distPluginDir) { + removePathIfExists(path.join(params.distPluginDir, "node_modules")); + } if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { return; } fs.symlinkSync(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); + + // Runtime wrappers re-export from dist/extensions//index.js, so Node + // resolves bare-specifier dependencies relative to the dist plugin directory. + // copy-bundled-plugin-metadata removes dist node_modules; restore the link here. + if (params.distPluginDir) { + removePathIfExists(path.join(params.distPluginDir, "node_modules")); + } + if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { + return; + } + fs.symlinkSync(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); + + if (params.distPluginDir) { + const distNodeModulesDir = path.join(params.distPluginDir, "node_modules"); + fs.symlinkSync(params.sourcePluginNodeModulesDir, distNodeModulesDir, symlinkType()); + } } export function stageBundledPluginRuntime(params = {}) { @@ -121,6 +140,7 @@ export function stageBundledPluginRuntime(params = {}) { stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); linkPluginNodeModules({ runtimePluginDir, + distPluginDir, sourcePluginNodeModulesDir, }); } diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index fe246e8fcfe..fef9a725799 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -49,6 +49,12 @@ describe("stageBundledPluginRuntime", () => { expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe( fs.realpathSync(sourcePluginNodeModulesDir), ); + + // dist/ also gets a node_modules symlink so bare-specifier resolution works + // from the actual code location that the runtime wrapper re-exports into + const distNodeModules = path.join(distPluginDir, "node_modules"); + expect(fs.lstatSync(distNodeModules).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(distNodeModules)).toBe(fs.realpathSync(sourcePluginNodeModulesDir)); }); it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index f3d76ab2e6e..b091b74d67c 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -715,6 +715,13 @@ line-height: 1.55; } +.config-raw-field .config-raw-toggle { + width: 32px; + height: 32px; + padding: 6px; + min-width: 32px; +} + /* Loading State */ .config-loading { display: flex; diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 1ec032e352f..7c1121e6bb8 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1060,7 +1060,7 @@ export function renderConfig(props: ConfigProps) { ` : nothing } -