From d7396d4ffa2f4b6673460121c8c6d3e9b7ad0591 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 05:08:21 +0100 Subject: [PATCH] fix(channels): keep status accessors config-only --- CHANGELOG.md | 1 + extensions/googlechat/src/accounts.ts | 16 +++++++- .../googlechat/src/channel-config.test.ts | 39 +++++++++++++++++++ .../googlechat/src/channel.deps.runtime.ts | 2 + extensions/googlechat/src/channel.setup.ts | 8 +++- extensions/googlechat/src/channel.ts | 12 ++++-- extensions/slack/src/accounts.ts | 19 +++++++++ extensions/slack/src/channel.setup.ts | 18 ++------- extensions/slack/src/shared.test.ts | 35 ++++++++++++++++- extensions/slack/src/shared.ts | 20 +--------- extensions/telegram/src/shared.test.ts | 30 +++++++++++++- extensions/telegram/src/shared.ts | 27 +++++++++++-- src/plugin-sdk/channel-config-helpers.test.ts | 25 ++++++++++++ 13 files changed, 209 insertions(+), 43 deletions(-) create mode 100644 extensions/googlechat/src/channel-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d644b6f3ba4..c6a1ca6bdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/status: keep Telegram, Slack, and Google Chat read-only allowlist/default-target accessors on config-only paths, so status and channel summaries do not resolve SecretRef-backed runtime credentials. Thanks @eusine. - Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine. - Gateway/sessions: align session abort wait semantics across `chat`, `agent`, and `sessions` server methods so abort RPCs return after the targeted sessions actually halt instead of resolving early while runs are still draining. (#74751) Thanks @BunsDev. - Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae. diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 4bf9112cc32..1bccd00653f 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -24,6 +24,10 @@ export type ResolvedGoogleChatAccount = { credentialsFile?: string; }; +export type GoogleChatConfigAccessorAccount = { + config: GoogleChatAccountConfig; +}; + const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const JsonRecordSchema = z.record(z.string(), z.unknown()); @@ -34,7 +38,7 @@ const { } = createAccountListHelpers("googlechat"); export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId }; -function mergeGoogleChatAccountConfig( +export function mergeGoogleChatAccountConfig( cfg: OpenClawConfig, accountId: string, ): GoogleChatAccountConfig { @@ -62,6 +66,16 @@ function mergeGoogleChatAccountConfig( return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig; } +export function resolveGoogleChatConfigAccessorAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): GoogleChatConfigAccessorAccount { + const accountId = normalizeAccountId( + params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount, + ); + return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) }; +} + function parseServiceAccount(value: unknown): Record | null { if (isSecretRef(value)) { return null; diff --git a/extensions/googlechat/src/channel-config.test.ts b/extensions/googlechat/src/channel-config.test.ts new file mode 100644 index 00000000000..c696cc70032 --- /dev/null +++ b/extensions/googlechat/src/channel-config.test.ts @@ -0,0 +1,39 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { describe, expect, it } from "vitest"; +import { googlechatPlugin } from "./channel.js"; + +describe("googlechatPlugin config adapter", () => { + it("keeps read-only accessors from resolving service account SecretRefs", () => { + const cfg = { + secrets: { + providers: { + google_chat_service_account: { + source: "file", + path: "/tmp/openclaw-missing-google-chat-service-account", + mode: "singleValue", + }, + }, + }, + channels: { + googlechat: { + serviceAccount: { + source: "file", + provider: "google_chat_service_account", + id: "value", + }, + dm: { + allowFrom: ["users/123"], + }, + defaultTo: "spaces/AAA", + }, + }, + } as OpenClawConfig; + + expect(googlechatPlugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([ + "users/123", + ]); + expect(googlechatPlugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe( + "spaces/AAA", + ); + }); +}); diff --git a/extensions/googlechat/src/channel.deps.runtime.ts b/extensions/googlechat/src/channel.deps.runtime.ts index 127a5d848e3..33ad946727f 100644 --- a/extensions/googlechat/src/channel.deps.runtime.ts +++ b/extensions/googlechat/src/channel.deps.runtime.ts @@ -15,7 +15,9 @@ export { type OpenClawConfig, } from "../runtime-api.js"; export { + type GoogleChatConfigAccessorAccount, listGoogleChatAccountIds, + resolveGoogleChatConfigAccessorAccount, resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount, type ResolvedGoogleChatAccount, diff --git a/extensions/googlechat/src/channel.setup.ts b/extensions/googlechat/src/channel.setup.ts index aaa5d093d48..66255078f63 100644 --- a/extensions/googlechat/src/channel.setup.ts +++ b/extensions/googlechat/src/channel.setup.ts @@ -7,7 +7,9 @@ import { import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { + type GoogleChatConfigAccessorAccount, listGoogleChatAccountIds, + resolveGoogleChatConfigAccessorAccount, resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount, type ResolvedGoogleChatAccount, @@ -24,10 +26,14 @@ const formatGoogleChatAllowFromEntry = (entry: string) => .replace(/^users\//i, ""), ); -const googleChatConfigAdapter = createScopedChannelConfigAdapter({ +const googleChatConfigAdapter = createScopedChannelConfigAdapter< + ResolvedGoogleChatAccount, + GoogleChatConfigAccessorAccount +>({ sectionKey: "googlechat", listAccountIds: listGoogleChatAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount), + resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount, defaultAccountId: resolveDefaultGoogleChatAccountId, clearBaseFields: [ "serviceAccount", diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 9aec7364353..abce2cc05ea 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -30,6 +30,8 @@ import { isGoogleChatUserTarget, listGoogleChatAccountIds, normalizeGoogleChatTarget, + type GoogleChatConfigAccessorAccount, + resolveGoogleChatConfigAccessorAccount, resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount, type ChannelMessageActionAdapter, @@ -65,10 +67,14 @@ const meta = { markdownCapable: true, }; -const googleChatConfigAdapter = createScopedChannelConfigAdapter({ +const googleChatConfigAdapter = createScopedChannelConfigAdapter< + ResolvedGoogleChatAccount, + GoogleChatConfigAccessorAccount +>({ sectionKey: "googlechat", listAccountIds: listGoogleChatAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount), + resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount, defaultAccountId: resolveDefaultGoogleChatAccountId, clearBaseFields: [ "serviceAccount", @@ -80,13 +86,13 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter account.config.dm?.allowFrom, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, formatAllowFrom: (allowFrom) => formatNormalizedAllowFromEntries({ allowFrom, normalizeEntry: formatAllowFromEntry, }), - resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo, + resolveDefaultTo: (account) => account.config.defaultTo, }); const googlechatActions: ChannelMessageActionAdapter = { diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 56ed28884a8..30fbc766f64 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -35,6 +35,11 @@ export type ResolvedSlackAccount = { config: SlackAccountConfig; } & SlackAccountSurfaceFields; +export type SlackConfigAccessorAccount = { + allowFrom: string[] | undefined; + defaultTo: string | undefined; +}; + const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack"); export const listSlackAccountIds = listAccountIds; export const resolveDefaultSlackAccountId = resolveDefaultAccountId; @@ -73,6 +78,20 @@ export function resolveSlackAccountAllowFrom(params: { return allowFrom ? mapAllowFromEntries(allowFrom) : undefined; } +export function resolveSlackConfigAccessorAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): SlackConfigAccessorAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSlackAccountId(params.cfg), + ); + const config = mergeSlackAccountConfig(params.cfg, accountId); + return { + allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId }), + defaultTo: config.defaultTo, + }; +} + export function resolveSlackAccountDmPolicy(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index bfce95a3376..38959e12ad9 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -6,9 +6,10 @@ import { import { type ResolvedSlackAccount } from "./accounts.js"; import { listSlackAccountIds, + resolveSlackConfigAccessorAccount, resolveDefaultSlackAccountId, resolveSlackAccount, - resolveSlackAccountAllowFrom, + type SlackConfigAccessorAccount, } from "./accounts.js"; import { type ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; @@ -23,25 +24,14 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard, })); -type SlackSetupConfigAccessorAccount = { - allowFrom: string[] | undefined; - defaultTo: string | undefined; -}; - const slackSetupConfigAdapter = createScopedChannelConfigAdapter< ResolvedSlackAccount, - SlackSetupConfigAccessorAccount + SlackConfigAccessorAccount >({ sectionKey: SLACK_CHANNEL, listAccountIds: listSlackAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), - resolveAccessorAccount: (params) => { - const account = resolveSlackAccount(params); - return { - allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }), - defaultTo: account.config.defaultTo, - }; - }, + resolveAccessorAccount: resolveSlackConfigAccessorAccount, defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], resolveAllowFrom: (account) => account.allowFrom, diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts index a21f23ab2fa..93b54ad6ab0 100644 --- a/extensions/slack/src/shared.test.ts +++ b/extensions/slack/src/shared.test.ts @@ -1,5 +1,6 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; -import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js"; +import { createSlackPluginBase, setSlackChannelAllowlist, slackConfigAdapter } from "./shared.js"; describe("createSlackPluginBase", () => { it("owns Slack native command name overrides", () => { @@ -56,3 +57,35 @@ describe("setSlackChannelAllowlist", () => { }); }); }); + +describe("slackConfigAdapter", () => { + it("keeps read-only accessors from resolving token SecretRefs", () => { + const cfg = { + secrets: { + providers: { + slack_bot: { + source: "file", + path: "/tmp/openclaw-missing-slack-bot-token", + mode: "singleValue", + }, + slack_app: { + source: "file", + path: "/tmp/openclaw-missing-slack-app-token", + mode: "singleValue", + }, + }, + }, + channels: { + slack: { + botToken: { source: "file", provider: "slack_bot", id: "value" }, + appToken: { source: "file", provider: "slack_app", id: "value" }, + allowFrom: ["U123"], + defaultTo: "C123", + }, + }, + } as unknown as OpenClawConfig; + + expect(slackConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]); + expect(slackConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe("C123"); + }); +}); diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 499c13a2087..257e13cbfde 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -7,16 +7,16 @@ import { import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, + resolveSlackConfigAccessorAccount, resolveDefaultSlackAccountId, resolveSlackAccount, - resolveSlackAccountAllowFrom, + type SlackConfigAccessorAccount, type ResolvedSlackAccount, } from "./accounts.js"; import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; import { slackDoctor } from "./doctor.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import type { OpenClawConfig } from "./runtime-api.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { slackSecurityAdapter } from "./security.js"; import { SLACK_CHANNEL } from "./setup-shared.js"; @@ -40,22 +40,6 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b return Boolean(account.appToken?.trim()); } -type SlackConfigAccessorAccount = { - allowFrom: string[] | undefined; - defaultTo: string | undefined; -}; - -function resolveSlackConfigAccessorAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): SlackConfigAccessorAccount { - const account = resolveSlackAccount(params); - return { - allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }), - defaultTo: account.config.defaultTo, - }; -} - export const slackConfigAdapter = createScopedChannelConfigAdapter< ResolvedSlackAccount, SlackConfigAccessorAccount diff --git a/extensions/telegram/src/shared.test.ts b/extensions/telegram/src/shared.test.ts index 601f0a7a9ac..8b2a438d651 100644 --- a/extensions/telegram/src/shared.test.ts +++ b/extensions/telegram/src/shared.test.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; import type { ResolvedTelegramAccount } from "./accounts.js"; -import { createTelegramPluginBase } from "./shared.js"; +import { createTelegramPluginBase, telegramConfigAdapter } from "./shared.js"; const telegramPluginBase = createTelegramPluginBase({ setupWizard: {} as never, @@ -166,4 +166,32 @@ describe("createTelegramPluginBase config duplicate token guard", () => { expect(await telegramPluginBase.config.isConfigured!(account, cfg)).toBe(false); expect(telegramPluginBase.config.unconfiguredReason?.(account, cfg)).toContain("unavailable"); }); + + it("keeps read-only accessors from resolving bot token SecretRefs", () => { + const cfg = { + secrets: { + providers: { + telegram_token: { + source: "file", + path: "/tmp/openclaw-missing-telegram-token", + mode: "singleValue", + }, + }, + }, + channels: { + telegram: { + botToken: { source: "file", provider: "telegram_token", id: "value" }, + allowFrom: ["1128540374256849009"], + defaultTo: "1498959610751750304", + }, + }, + } as unknown as OpenClawConfig; + + expect(telegramConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([ + "1128540374256849009", + ]); + expect(telegramConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe( + "1498959610751750304", + ); + }); }); diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index ae547b7c205..0d2098fd483 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -7,11 +7,12 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-types"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, + mergeTelegramAccountConfig, resolveDefaultTelegramAccountId, resolveTelegramAccount, type ResolvedTelegramAccount, @@ -32,6 +33,10 @@ import { namedAccountPromotionKeys, singleAccountKeysToMove } from "./setup-cont export const TELEGRAM_CHANNEL = "telegram" as const; +type TelegramConfigAccessorAccount = { + config: TelegramAccountConfig; +}; + export function findTelegramTokenOwnerAccountId(params: { cfg: OpenClawConfig; accountId: string; @@ -99,17 +104,31 @@ function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boole return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); } -export const telegramConfigAdapter = createScopedChannelConfigAdapter({ +function resolveTelegramConfigAccessorAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): TelegramConfigAccessorAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultTelegramAccountId(params.cfg), + ); + return { config: mergeTelegramAccountConfig(params.cfg, accountId) }; +} + +export const telegramConfigAdapter = createScopedChannelConfigAdapter< + ResolvedTelegramAccount, + TelegramConfigAccessorAccount +>({ sectionKey: TELEGRAM_CHANNEL, listAccountIds: listTelegramAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount), + resolveAccessorAccount: resolveTelegramConfigAccessorAccount, inspectAccount: adaptScopedAccountAccessor(inspectTelegramAccount), defaultAccountId: resolveDefaultTelegramAccountId, clearBaseFields: ["botToken", "tokenFile", "name"], - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + resolveAllowFrom: (account) => account.config.allowFrom, formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, + resolveDefaultTo: (account) => account.config.defaultTo, }); export function createTelegramPluginBase(params: { diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index cf46fed35f2..635ff04c186 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -413,6 +413,31 @@ describe("createScopedChannelConfigAdapter", () => { }); expectAdapterAllowFromAndDefaultTo(adapter); }); + + it("keeps read-only accessors on the accessor resolver", () => { + const adapter = createScopedChannelConfigAdapter< + { accountId: string; token: string }, + { allowFrom: string[]; defaultTo: string } + >({ + sectionKey: "demo", + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("runtime account resolver should not run for read-only accessors"); + }, + resolveAccessorAccount: ({ accountId }) => ({ + allowFrom: [accountId ?? "default"], + defaultTo: " room:123 ", + }), + defaultAccountId: resolveDefaultAccountId, + clearBaseFields: ["token"], + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry)), + resolveDefaultTo: (account) => account.defaultTo, + }); + + expect(adapter.resolveAllowFrom?.({ cfg: {}, accountId: "default" })).toEqual(["default"]); + expect(adapter.resolveDefaultTo?.({ cfg: {}, accountId: "default" })).toBe("room:123"); + }); }); describe("createScopedDmSecurityResolver", () => {