From 08b1ff73e2f35ef02eb5530d700f017f44c974b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 03:49:37 -0400 Subject: [PATCH] refactor(channels): fix account resolution and setup isolation (#114395) * refactor(channels): fix account resolution and setup isolation * fix(channels): keep account resolvers safely destructurable --- .../.generated/plugin-sdk-api-baseline.sha256 | 8 +- extensions/clickclack/src/accounts.ts | 15 +-- extensions/clickclack/src/setup-core.ts | 62 +-------- extensions/discord/src/accounts.ts | 18 +-- extensions/googlechat/src/accounts.ts | 16 +-- extensions/imessage/src/accounts.ts | 22 ++- extensions/irc/src/accounts.ts | 62 +++------ extensions/line/src/accounts.test.ts | 25 +++- extensions/line/src/accounts.ts | 51 ++++--- extensions/line/src/setup-core.test.ts | 59 +++++++- extensions/line/src/setup-core.ts | 64 +++------ extensions/line/src/setup-runtime-api.ts | 1 - .../mattermost/src/mattermost/accounts.ts | 33 ++--- extensions/nextcloud-talk/src/accounts.ts | 22 +-- extensions/nextcloud-talk/src/setup-core.ts | 66 ++------- .../nextcloud-talk/src/setup-surface.ts | 13 +- extensions/nextcloud-talk/src/setup.test.ts | 53 +++++++- extensions/nostr/src/types.ts | 49 ++----- extensions/qa-channel/src/accounts.ts | 15 +-- extensions/qqbot/src/bridge/config-shared.ts | 54 ++------ extensions/qqbot/src/config.test.ts | 86 +++++++++++- extensions/raft/src/accounts.ts | 20 ++- extensions/sms/src/accounts.ts | 46 ++----- extensions/synology-chat/src/accounts.ts | 47 +++---- extensions/synology-chat/src/core.test.ts | 7 + extensions/telegram/src/account-selection.ts | 41 ++---- extensions/telegram/src/accounts.test.ts | 22 +++ extensions/tlon/src/core.test.ts | 5 + extensions/tlon/src/types.ts | 40 +++--- extensions/twitch/src/config.test.ts | 20 ++- extensions/twitch/src/config.ts | 97 ++++++------- extensions/twitch/src/plugin.test.ts | 71 +++++++++- extensions/twitch/src/plugin.ts | 30 +---- extensions/twitch/src/setup-surface.ts | 30 +---- extensions/zalo/src/accounts.ts | 34 ++--- extensions/zalouser/src/accounts.ts | 21 +-- src/channels/plugins/account-helpers.test.ts | 127 ++++++++++++++++++ src/channels/plugins/account-helpers.ts | 45 ++++++- src/channels/plugins/setup-helpers.test.ts | 85 ++++++++++++ src/channels/plugins/setup-helpers.ts | 15 ++- 40 files changed, 889 insertions(+), 708 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 620841133d41..4118d33a8a72 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,7 +1,7 @@ -7a8753532854ff6f6d2a0c8e6386653adce285f9f6a9b5e56e88c9ea49e79cc2 module/account-core -e7519fd1b7ff7003a6c8b9b640ffedd443a3754a10efc82877d48cd98cc6ba28 module/account-helpers +cada1ccdba113c813b6c012d4e119e2a67e23cf7015ebfd62403ed8e74d83cbd module/account-core +4c7a44c538a6856a0861300f05a5df16cb7805fbf1b22c5b772c0a6ad71c45ef module/account-helpers cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account-id -6b674e7aa4006240227c16eb5c74360f1d67639e8f5580ad72499d6ed28283c4 module/account-resolution +8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness b80e26a4dcf2c246d01755b8da4bee272637f11ced1689fa561397c1979613e5 module/agent-harness-runtime @@ -119,7 +119,7 @@ e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret- c81b9702c192d574413fc1df9a57c73128652a25de205eec55d6b47549349283 module/security-runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion 32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime -23cc02cbfb0a0bfa41adc8f02f5f22738781495250c8cee06c18f18bfd283afe module/setup +36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup 21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools 18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime diff --git a/extensions/clickclack/src/accounts.ts b/extensions/clickclack/src/accounts.ts index a9cfba3f3be8..8aaf25f60358 100644 --- a/extensions/clickclack/src/accounts.ts +++ b/extensions/clickclack/src/accounts.ts @@ -7,7 +7,6 @@ import { hasConfiguredAccountValue, } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution"; import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution-runtime"; import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; @@ -28,8 +27,11 @@ const DEFAULT_DISCUSSIONS_SECTION = "Sessions"; const { listAccountIds: listClickClackAccountIds, resolveDefaultAccountId: resolveDefaultClickClackAccountId, -} = createAccountListHelpers("clickclack", { + resolveAccountConfig: resolveMergedClickClackAccountConfig, +} = createAccountListHelpers("clickclack", { normalizeAccountId, + omitKeys: ["defaultAccount"], + nestedObjectKeys: ["discussions"], hasImplicitDefaultAccount: (cfg) => { const channel = cfg.channels?.clickclack; return Boolean( @@ -49,14 +51,7 @@ export function resolveClickClackAccountConfig( accountId: string, ): ClickClackAccountConfig { const channel = cfg.channels?.clickclack; - const merged = resolveMergedAccountConfig({ - channelConfig: cfg.channels?.clickclack as ClickClackAccountConfig | undefined, - accounts: channel?.accounts, - accountId, - omitKeys: ["defaultAccount"], - nestedObjectKeys: ["discussions"], - normalizeAccountId, - }); + const merged = resolveMergedClickClackAccountConfig(cfg, accountId); const account = resolveNormalizedAccountEntry(channel?.accounts, accountId, normalizeAccountId); const accountTokenFile = account?.tokenFile?.trim(); if (accountTokenFile) { diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 8d17c13d0866..2f055804f346 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -9,9 +9,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { applyAccountNameToChannelSection, - applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, moveSingleAccountChannelSectionToDefaultAccount, + patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime"; import { resolveClickClackAccountConfig } from "./accounts.js"; @@ -160,6 +160,7 @@ export function applyClickClackSetupConfigPatch(params: { accountId: string; name?: string; patch: Record; + clearFields?: readonly string[]; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const scopedConfig = @@ -183,62 +184,15 @@ export function applyClickClackSetupConfigPatch(params: { channelKey: channel, }) : namedConfig; - return applySetupAccountConfigPatch({ + return patchScopedAccountConfig({ cfg: next, channelKey: channel, accountId, patch: params.patch, + ...(params.clearFields ? { clearFields: params.clearFields } : {}), }); } -function clearClickClackSetupConfigFields(params: { - cfg: OpenClawConfig; - accountId: string; - fields: string[]; -}): OpenClawConfig { - const clickclack = (params.cfg.channels as Record | undefined)?.clickclack as - | (Record & { accounts?: Record> }) - | undefined; - if (!clickclack) { - return params.cfg; - } - const accountId = normalizeAccountId(params.accountId); - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextClickClack = { ...clickclack }; - for (const field of params.fields) { - delete nextClickClack[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - clickclack: nextClickClack, - }, - } as OpenClawConfig; - } - const currentAccount = clickclack.accounts?.[accountId]; - if (!currentAccount) { - return params.cfg; - } - const nextAccount = { ...currentAccount }; - for (const field of params.fields) { - delete nextAccount[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - clickclack: { - ...clickclack, - accounts: { - ...clickclack.accounts, - [accountId]: nextAccount, - }, - }, - }, - } as OpenClawConfig; -} - export function applyClickClackCredentialConfig(params: { cfg: OpenClawConfig; accountId: string; @@ -253,9 +207,10 @@ export function applyClickClackCredentialConfig(params: { : params.token !== undefined ? ["tokenFile"] : []; - const next = applyClickClackSetupConfigPatch({ + return applyClickClackSetupConfigPatch({ cfg: params.cfg, accountId: params.accountId, + clearFields: fieldsToClear, patch: params.useEnv ? {} : params.tokenFile @@ -264,11 +219,6 @@ export function applyClickClackCredentialConfig(params: { ? { token: params.token } : {}, }); - return clearClickClackSetupConfigFields({ - cfg: next, - accountId: params.accountId, - fields: fieldsToClear, - }); } export const clickClackSetupAdapter: ChannelSetupAdapter = { diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 266067232b69..cac1aa9f4e4f 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -2,7 +2,6 @@ import { createAccountActionGate, createAccountListHelpers, - resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-helpers"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { @@ -26,11 +25,16 @@ export type ResolvedDiscordAccount = { config: DiscordAccountConfig; }; -const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("discord", { +const { + listAccountIds, + resolveDefaultAccountId, + resolveAccountConfig: resolveMergedDiscordAccountConfig, +} = createAccountListHelpers("discord", { implicitDefaultAccount: { channelKeys: ["token"], envVars: ["DISCORD_BOT_TOKEN"], }, + nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"], }); export const listDiscordAccountIds = listAccountIds; export const resolveDefaultDiscordAccountId = resolveDefaultAccountId; @@ -46,15 +50,7 @@ export function mergeDiscordAccountConfig( cfg: OpenClawConfig, accountId: string, ): DiscordAccountConfig { - const merged = resolveMergedAccountConfig({ - channelConfig: cfg.channels?.discord as DiscordAccountConfig | undefined, - accounts: cfg.channels?.discord?.accounts as - | Record> - | undefined, - accountId, - nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"], - }); - return merged; + return resolveMergedDiscordAccountConfig(cfg, accountId); } export function resolveDiscordAccountAllowFrom(params: { diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 0f46a7a8198b..cf8a015c3b69 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -1,11 +1,10 @@ // Googlechat plugin module implements accounts behavior. +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { - createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, resolveAccountEntry, - resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-resolution"; import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared"; import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime"; @@ -47,11 +46,14 @@ const JsonRecordSchema = z.record(z.string(), z.unknown()); const { listAccountIds: listGoogleChatAccountIds, resolveDefaultAccountId: resolveDefaultGoogleChatAccountId, -} = createAccountListHelpers("googlechat", { + resolveAccountConfig: resolveMergedGoogleChatAccountConfig, +} = createAccountListHelpers("googlechat", { implicitDefaultAccount: { channelKeys: ["serviceAccount", "serviceAccountFile"], envVars: [ENV_SERVICE_ACCOUNT, ENV_SERVICE_ACCOUNT_FILE], }, + omitKeys: ["defaultAccount"], + nestedObjectKeys: ["botLoopProtection"], }); export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId }; @@ -60,13 +62,7 @@ function mergeGoogleChatAccountConfig( accountId: string, ): GoogleChatAccountConfig { const raw = cfg.channels?.["googlechat"] ?? {}; - const base = resolveMergedAccountConfig({ - channelConfig: raw as GoogleChatAccountConfig, - accounts: raw.accounts as Record> | undefined, - accountId, - omitKeys: ["defaultAccount"], - nestedObjectKeys: ["botLoopProtection"], - }); + const base = resolveMergedGoogleChatAccountConfig(cfg, accountId); const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {}; if (accountId === DEFAULT_ACCOUNT_ID) { return base; diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index dcf40ed2e314..d9ae0618038c 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,11 +1,7 @@ import { statSync } from "node:fs"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { - createAccountListHelpers, - normalizeAccountId, - resolveMergedAccountConfig, - type OpenClawConfig, -} from "openclaw/plugin-sdk/account-resolution"; +import { normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk/account-resolution"; // Imessage plugin module implements accounts behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; @@ -21,7 +17,11 @@ export type ResolvedIMessageAccount = { configured: boolean; }; -const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage", { +const { + listAccountIds, + resolveDefaultAccountId, + resolveAccountConfig: resolveMergedIMessageAccountConfig, +} = createAccountListHelpers("imessage", { implicitDefaultAccount: { channelKeys: ["cliPath", "dbPath"], }, @@ -77,13 +77,7 @@ function mergeIMessageStreamingConfig( function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig { const accountConfig = resolveIMessageAccountConfig(cfg, accountId); - const merged = resolveMergedAccountConfig({ - channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined, - accounts: cfg.channels?.imessage?.accounts as - | Record> - | undefined, - accountId, - }); + const merged = resolveMergedIMessageAccountConfig(cfg, accountId); const streaming = mergeIMessageStreamingConfig( (cfg.channels?.imessage as Record | undefined)?.streaming, (accountConfig as Record | undefined)?.streaming, diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 68b016be04b9..a66718bc9e64 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,7 +1,7 @@ // Irc plugin module implements accounts behavior. +import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core"; import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/channel-core"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; @@ -55,28 +55,22 @@ function parseIntEnv(value?: string): number | undefined { return parsed; } -const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } = - createAccountListHelpers("irc", { - normalizeAccountId, - hasImplicitDefaultAccount: (cfg) => - Boolean( - (cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) && - (cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()), - ), - }); +const { + listAccountIds: listIrcAccountIds, + resolveDefaultAccountId: resolveDefaultIrcAccountId, + resolveAccountConfig: mergeIrcAccountConfig, +} = createAccountListHelpers("irc", { + normalizeAccountId, + omitKeys: ["defaultAccount"], + nestedObjectKeys: ["nickserv"], + hasImplicitDefaultAccount: (cfg) => + Boolean( + (cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) && + (cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()), + ), +}); export { listIrcAccountIds, resolveDefaultIrcAccountId }; -function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined, - accounts: cfg.channels?.irc?.accounts as Record> | undefined, - accountId, - omitKeys: ["defaultAccount"], - normalizeAccountId, - nestedObjectKeys: ["nickserv"], - }); -} - function resolvePassword(accountId: string, merged: IrcAccountConfig) { if (accountId === DEFAULT_ACCOUNT_ID) { const envPassword = process.env.IRC_PASSWORD?.trim(); @@ -162,7 +156,6 @@ export function resolveIrcAccount(params: { cfg: CoreConfig; accountId?: string | null; }): ResolvedIrcAccount { - const hasExplicitAccountId = Boolean(params.accountId?.trim()); const baseEnabled = params.cfg.channels?.irc?.enabled !== false; const resolve = (accountId: string) => { @@ -249,24 +242,13 @@ export function resolveIrcAccount(params: { } satisfies ResolvedIrcAccount; }; - const normalized = normalizeAccountId(params.accountId); - const primary = resolve(normalized); - if (hasExplicitAccountId) { - return primary; - } - if (primary.configured) { - return primary; - } - - const fallbackId = resolveDefaultIrcAccountId(params.cfg); - if (fallbackId === primary.accountId) { - return primary; - } - const fallback = resolve(fallbackId); - if (!fallback.configured) { - return primary; - } - return fallback; + return resolveAccountWithDefaultFallback({ + accountId: params.accountId, + normalizeAccountId, + resolvePrimary: resolve, + hasCredential: (account) => account.configured, + resolveDefaultAccountId: () => resolveDefaultIrcAccountId(params.cfg), + }); } export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] { diff --git a/extensions/line/src/accounts.test.ts b/extensions/line/src/accounts.test.ts index b621737f3793..7f34138f7788 100644 --- a/extensions/line/src/accounts.test.ts +++ b/extensions/line/src/accounts.test.ts @@ -5,7 +5,12 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveLineAccount, resolveDefaultLineAccountId, normalizeAccountId } from "./accounts.js"; +import { + listLineAccountIds, + normalizeAccountId, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "./accounts.js"; describe("LINE accounts", () => { const tempDirs: string[] = []; @@ -391,6 +396,24 @@ describe("LINE accounts", () => { }); }); + describe("listLineAccountIds", () => { + it("keeps unconfigured channels empty", () => { + expect(listLineAccountIds({})).toEqual([]); + }); + + it("preserves configured named-account insertion order", () => { + expect( + listLineAccountIds({ + channels: { + line: { + accounts: { work: {}, alerts: {} }, + }, + }, + }), + ).toEqual(["work", "alerts"]); + }); + }); + describe("resolveDefaultLineAccountId", () => { it.each([ { diff --git a/extensions/line/src/accounts.ts b/extensions/line/src/accounts.ts index 9724f61ed515..e5a428037353 100644 --- a/extensions/line/src/accounts.ts +++ b/extensions/line/src/accounts.ts @@ -1,11 +1,15 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Line plugin module implements accounts behavior. import { DEFAULT_ACCOUNT_ID, normalizeAccountId as normalizeSharedAccountId, normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution"; -import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution"; +import { + resolveAccountEntry, + resolveListedDefaultAccountId, + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-resolution"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import type { LineAccountConfig, @@ -16,6 +20,12 @@ import type { ResolvedLineAccount, } from "./types.js"; +const { resolveAccountConfig: resolveMergedLineAccountConfig } = createAccountListHelpers< + Record & LineConfig +>("line", { + omitKeys: ["defaultAccount"], +}); + function readCredentialFile(filePath: string, configPath: string) { return tryReadSecretFileSync( filePath, @@ -162,18 +172,10 @@ export function resolveLineAccount(params: { accountConfig, }); - const { - accounts: _ignoredAccounts, - defaultAccount: _ignoredDefaultAccount, - ...lineBase - } = (lineConfig ?? {}) as LineConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const mergedConfig: LineConfig & LineAccountConfig = { - ...lineBase, - ...accountConfig, - }; + const mergedConfig: LineConfig & LineAccountConfig = resolveMergedLineAccountConfig( + cfg, + accountId, + ); const baseEnabled = lineConfig?.enabled !== false; const accountEnabled = accountConfig?.enabled !== false; @@ -226,20 +228,13 @@ export function listLineAccountIds(cfg: OpenClawConfig): string[] { } export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string { - const preferred = normalizeOptionalAccountId( - (cfg.channels?.line as LineConfig | undefined)?.defaultAccount, - ); - if ( - preferred && - listLineAccountIds(cfg).some((accountId) => normalizeSharedAccountId(accountId) === preferred) - ) { - return preferred; - } - const ids = listLineAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; + return resolveListedDefaultAccountId({ + accountIds: listLineAccountIds(cfg), + configuredDefaultAccountId: normalizeOptionalAccountId( + (cfg.channels?.line as LineConfig | undefined)?.defaultAccount, + ), + normalizeListedAccountId: normalizeSharedAccountId, + }); } export function normalizeAccountId(accountId: string | undefined): string { diff --git a/extensions/line/src/setup-core.test.ts b/extensions/line/src/setup-core.test.ts index 50e196a91357..8d4162405003 100644 --- a/extensions/line/src/setup-core.test.ts +++ b/extensions/line/src/setup-core.test.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/setup"; // Guards the shipped `--token` alias: released CLIs configured LINE through the // shared token envelope switch, which must keep writing channelAccessToken. import { describe, expect, it } from "vitest"; -import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupAdapter, patchLineAccountConfig } from "./setup-core.js"; type LineChannelConfig = { channelAccessToken?: string }; @@ -28,3 +28,60 @@ describe("line setup token alias", () => { expect(applied.channelAccessToken).toBe("explicit-token"); }); }); + +describe("LINE scoped setup config", () => { + it("explicitly re-enables an existing disabled named account", () => { + const cfg = patchLineAccountConfig({ + cfg: { + channels: { + line: { + enabled: false, + accounts: { + work: { + enabled: false, + channelAccessToken: "old-token", + }, + }, + }, + }, + }, + accountId: "work", + enabled: true, + patch: { channelAccessToken: "new-token" }, + }); + + expect(cfg.channels?.line?.enabled).toBe(true); + expect(cfg.channels?.line?.accounts?.work).toMatchObject({ + enabled: true, + channelAccessToken: "new-token", + }); + }); + + it("clears only the selected named-account credential before applying its replacement", () => { + const cfg = patchLineAccountConfig({ + cfg: { + channels: { + line: { + channelAccessToken: "default-token", + accounts: { + work: { + channelAccessToken: "old-token", + tokenFile: "/run/secrets/line-work", + }, + }, + }, + }, + }, + accountId: "work", + enabled: true, + clearFields: ["channelAccessToken", "tokenFile"], + patch: { channelAccessToken: "new-token" }, + }); + + expect(cfg.channels?.line?.channelAccessToken).toBe("default-token"); + expect(cfg.channels?.line?.accounts?.work).toEqual({ + enabled: true, + channelAccessToken: "new-token", + }); + }); +}); diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 038ec34a8474..30949a76b66a 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -5,14 +5,16 @@ import type { ChannelSetupInput, OpenClawConfig, } from "openclaw/plugin-sdk/setup"; -import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup"; +import { + createSetupInputPresenceValidator, + patchScopedAccountConfig, +} from "openclaw/plugin-sdk/setup"; import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js"; import { DEFAULT_ACCOUNT_ID, listLineAccountIds, normalizeAccountId, resolveLineAccount, - type LineConfig, } from "./setup-runtime-api.js"; type LineSetupInput = ChannelSetupInput & { @@ -28,53 +30,19 @@ export function patchLineAccountConfig(params: { clearFields?: string[]; enabled?: boolean; }): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; - const clearFields = params.clearFields ?? []; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextLine = { ...lineConfig } as Record; - for (const field of clearFields) { - delete nextLine[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...nextLine, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; - } - - const nextAccount = { - ...lineConfig.accounts?.[accountId], - } as Record; - for (const field of clearFields) { - delete nextAccount[field]; - } - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...lineConfig, - ...(params.enabled ? { enabled: true } : {}), - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...nextAccount, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }, + return patchScopedAccountConfig({ + cfg: params.cfg, + channelKey: "line", + accountId: params.accountId, + patch: params.patch, + accountPatch: { + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, }, - }; + ...(params.clearFields ? { clearFields: params.clearFields } : {}), + ensureChannelEnabled: Boolean(params.enabled), + ensureAccountEnabled: false, + }); } export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { diff --git a/extensions/line/src/setup-runtime-api.ts b/extensions/line/src/setup-runtime-api.ts index fd8b164d99f1..f35aa06930c4 100644 --- a/extensions/line/src/setup-runtime-api.ts +++ b/extensions/line/src/setup-runtime-api.ts @@ -7,4 +7,3 @@ export { } from "openclaw/plugin-sdk/setup"; export type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js"; -export type { LineConfig } from "./types.js"; diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index 73bb6b1fb966..7e74b09ea980 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -4,7 +4,6 @@ import { hasConfiguredAccountValue, } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution"; import { resolveChannelStreamingBlockCoalesce, resolveChannelStreamingBlockEnabled, @@ -47,7 +46,13 @@ export type ResolvedMattermostAccount = { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; }; -const mattermostAccountHelpers = createAccountListHelpers("mattermost", { +const { + listAccountIds: listMattermostAccountIds, + resolveDefaultAccountId: resolveDefaultMattermostAccountId, + resolveAccountConfig: mergeMattermostAccountConfig, +} = createAccountListHelpers("mattermost", { + omitKeys: ["defaultAccount"], + nestedObjectKeys: ["commands"], hasImplicitDefaultAccount: (cfg) => { const mattermost = cfg.channels?.mattermost; return Boolean( @@ -56,29 +61,7 @@ const mattermostAccountHelpers = createAccountListHelpers("mattermost", { ); }, }); - -export function listMattermostAccountIds(cfg: OpenClawConfig): string[] { - return mattermostAccountHelpers.listAccountIds(cfg); -} - -export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string { - return mattermostAccountHelpers.resolveDefaultAccountId(cfg); -} - -function mergeMattermostAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): MattermostAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined, - accounts: cfg.channels?.mattermost?.accounts as - | Record> - | undefined, - accountId, - omitKeys: ["defaultAccount"], - nestedObjectKeys: ["commands"], - }); -} +export { listMattermostAccountIds, resolveDefaultMattermostAccountId }; function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined { if (config.chatmode === "oncall") { diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index e90cb133db37..fb28e589ebf3 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -1,12 +1,11 @@ // Nextcloud Talk plugin module implements accounts behavior. import { - createAccountListHelpers, DEFAULT_ACCOUNT_ID, hasConfiguredAccountValue, normalizeAccountId, resolveAccountWithDefaultFallback, - resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-core"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { normalizeLowercaseStringOrEmpty, @@ -46,8 +45,10 @@ export type ResolvedNextcloudTalkAccount = { const { listAccountIds: listNextcloudTalkAccountIdsInternal, resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId, -} = createAccountListHelpers("nextcloud-talk", { + resolveAccountConfig: mergeNextcloudTalkAccountConfig, +} = createAccountListHelpers("nextcloud-talk", { normalizeAccountId, + omitKeys: ["defaultAccount"], hasImplicitDefaultAccount: (cfg) => { const channel = cfg.channels?.["nextcloud-talk"]; return Boolean( @@ -66,21 +67,6 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] { return ids; } -function mergeNextcloudTalkAccountConfig( - cfg: CoreConfig, - accountId: string, -): NextcloudTalkAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined, - accounts: cfg.channels?.["nextcloud-talk"]?.accounts as - | Record> - | undefined, - accountId, - omitKeys: ["defaultAccount"], - normalizeAccountId, - }); -} - function resolveNextcloudTalkSecret( cfg: CoreConfig, opts: { accountId?: string }, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index a46d0ad8dbd7..3501087f1e0a 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -6,7 +6,7 @@ import { type ChannelSetupInput, } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, @@ -35,7 +35,6 @@ type NextcloudSetupInput = ChannelSetupInput & { url?: string; password?: string; }; -type NextcloudTalkSection = NonNullable["nextcloud-talk"]; function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; @@ -59,63 +58,17 @@ export function setNextcloudTalkAccountConfig( cfg: CoreConfig, accountId: string, updates: Record, + clearFields?: readonly string[], ): CoreConfig { return patchScopedAccountConfig({ cfg, channelKey: channel, accountId, patch: updates, + ...(clearFields ? { clearFields } : {}), }) as CoreConfig; } -export function clearNextcloudTalkAccountFields( - cfg: CoreConfig, - accountId: string, - fields: string[], -): CoreConfig { - const section = cfg.channels?.["nextcloud-talk"]; - if (!section) { - return cfg; - } - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextSection = { ...section } as Record; - for (const field of fields) { - delete nextSection[field]; - } - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": nextSection as NextcloudTalkSection, - }, - } as CoreConfig; - } - - const currentAccount = section.accounts?.[accountId]; - if (!currentAccount) { - return cfg; - } - - const nextAccount = { ...currentAccount } as Record; - for (const field of fields) { - delete nextAccount[field]; - } - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - ...section, - accounts: { - ...section.accounts, - [accountId]: nextAccount as NonNullable[string], - }, - }, - }, - } as CoreConfig; -} - async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -231,12 +184,6 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { accountId, name: setupInput.name, }); - const next = setupInput.useEnv - ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ - "botSecret", - "botSecretFile", - ]) - : namedConfig; const patch = { baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), ...(setupInput.useEnv @@ -247,7 +194,12 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { ? { botSecret: setupInput.secret } : {}), }; - return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); + return setNextcloudTalkAccountConfig( + namedConfig as CoreConfig, + accountId, + patch, + setupInput.useEnv ? ["botSecret", "botSecretFile"] : undefined, + ); }, }; diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index f847e671eaa6..3d7c53b5bf1a 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -13,7 +13,6 @@ import { import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { - clearNextcloudTalkAccountFields, nextcloudTalkDmPolicy, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, @@ -102,10 +101,8 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { accountId === DEFAULT_ACCOUNT_ID ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET) : undefined, - patchAccount: ({ cfg, accountId, patch, clearFields }) => { - const cleared = clearNextcloudTalkAccountFields(cfg as CoreConfig, accountId, clearFields); - return setNextcloudTalkAccountConfig(cleared, accountId, patch); - }, + patchAccount: ({ cfg, accountId, patch, clearFields }) => + setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, patch, clearFields), useEnv: { clearFields: ["botSecret", "botSecretFile"], patch: (account) => ({ baseUrl: account.baseUrl }), @@ -135,11 +132,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { ), shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1", patchAccount: ({ cfg, accountId, patch, clearFields }) => - setNextcloudTalkAccountConfig( - clearNextcloudTalkAccountFields(cfg as CoreConfig, accountId, clearFields), - accountId, - patch, - ), + setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, patch, clearFields), set: { clearFields: ["apiPassword", "apiPasswordFile"] }, }), ], diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index 61c13bcdace9..2612a8a4047b 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -7,7 +7,6 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { describe, expect, it } from "vitest"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { - clearNextcloudTalkAccountFields, nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, @@ -76,9 +75,13 @@ describe("nextcloud talk setup", () => { }, }); - expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toEqual({ + const clearedDefault = setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {}, [ + "botSecret", + ]); + expect(clearedDefault).toEqual({ channels: { "nextcloud-talk": { + enabled: true, baseUrl: "https://cloud.example.com", accounts: { work: { @@ -90,17 +93,17 @@ describe("nextcloud talk setup", () => { }, }, }); - expect( - clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]), - ).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]); + expect(clearedDefault).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]); - expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])).toEqual({ + expect(setNextcloudTalkAccountConfig(cfg, "work", {}, ["botSecret", "botSecretFile"])).toEqual({ channels: { "nextcloud-talk": { + enabled: true, baseUrl: "https://cloud.example.com", botSecret: "top-secret", accounts: { work: { + enabled: true, apiPassword: "api-secret", }, }, @@ -385,6 +388,44 @@ describe("nextcloud talk setup", () => { expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); }); + + it("replaces only the selected account's API password when the wizard sets a credential", async () => { + const credential = expectDefined( + nextcloudTalkSetupWizard.credentials[1], + "Nextcloud Talk API credential", + ); + const next = await credential.applySet?.({ + cfg: { + channels: { + "nextcloud-talk": { + botSecret: "root-secret", + accounts: { + work: { + baseUrl: "https://cloud.example.com", + botSecret: "work-secret", + apiUser: "bot", + apiPassword: "old-password", + apiPasswordFile: "/run/secrets/old-api-password", + }, + }, + }, + }, + }, + accountId: "work", + credentialValues: {}, + value: "new-password", + resolvedValue: "new-password", + }); + + expect(next?.channels?.["nextcloud-talk"]?.botSecret).toBe("root-secret"); + expect(next?.channels?.["nextcloud-talk"]?.accounts?.work).toEqual({ + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "work-secret", + apiUser: "bot", + apiPassword: "new-password", + }); + }); }); describe("resolveNextcloudTalkAccount", () => { diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 3faf19a0a80f..8022d29d2e24 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -1,13 +1,10 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Nostr type declarations define plugin contracts. import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; -import { - listCombinedAccountIds, - resolveListedDefaultAccountId, -} from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sdk/secret-input"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -38,38 +35,20 @@ export interface ResolvedNostrAccount { config: NostrAccountConfig; } -function resolveConfiguredDefaultNostrAccountId(cfg: OpenClawConfig): string | undefined { - const nostrCfg = (cfg.channels as Record | undefined)?.nostr as - | NostrAccountConfig - | undefined; - return normalizeOptionalAccountId(nostrCfg?.defaultAccount); -} +const { + listAccountIds: listNostrAccountIds, + resolveDefaultAccountId: resolveDefaultNostrAccountId, +} = createAccountListHelpers("nostr", { + fallbackAccountIdWhenEmpty: false, + resolveImplicitAccountId: (cfg) => { + const account = cfg.channels?.nostr as NostrAccountConfig | undefined; + return normalizeSecretInputString(account?.privateKey) + ? (normalizeOptionalAccountId(account?.defaultAccount) ?? DEFAULT_ACCOUNT_ID) + : undefined; + }, +}); -/** - * List all configured Nostr account IDs - */ -export function listNostrAccountIds(cfg: OpenClawConfig): string[] { - const nostrCfg = (cfg.channels as Record | undefined)?.nostr as - | NostrAccountConfig - | undefined; - const privateKey = normalizeSecretInputString(nostrCfg?.privateKey); - return listCombinedAccountIds({ - configuredAccountIds: [], - implicitAccountId: privateKey - ? (resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID) - : undefined, - }); -} - -/** - * Get the default account ID - */ -export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string { - return resolveListedDefaultAccountId({ - accountIds: listNostrAccountIds(cfg), - configuredDefaultAccountId: resolveConfiguredDefaultNostrAccountId(cfg), - }); -} +export { listNostrAccountIds, resolveDefaultNostrAccountId }; /** * Resolve a Nostr account from config diff --git a/extensions/qa-channel/src/accounts.ts b/extensions/qa-channel/src/accounts.ts index 7fa6eecc2272..59487ba109e3 100644 --- a/extensions/qa-channel/src/accounts.ts +++ b/extensions/qa-channel/src/accounts.ts @@ -1,7 +1,6 @@ // Qa Channel plugin module implements accounts behavior. import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CoreConfig, QaChannelAccountConfig, ResolvedQaChannelAccount } from "./types.js"; @@ -10,8 +9,10 @@ const DEFAULT_POLL_TIMEOUT_MS = 1_000; const { listAccountIds: listQaChannelAccountIds, resolveDefaultAccountId: resolveDefaultQaChannelAccountId, -} = createAccountListHelpers("qa-channel", { + resolveAccountConfig: resolveMergedQaAccountConfig, +} = createAccountListHelpers("qa-channel", { normalizeAccountId, + omitKeys: ["defaultAccount"], implicitDefaultAccount: { channelKeys: ["baseUrl"], }, @@ -19,16 +20,6 @@ const { export { listQaChannelAccountIds, resolveDefaultQaChannelAccountId }; -function resolveMergedQaAccountConfig(cfg: CoreConfig, accountId: string): QaChannelAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.["qa-channel"] as QaChannelAccountConfig | undefined, - accounts: cfg.channels?.["qa-channel"]?.accounts, - accountId, - omitKeys: ["defaultAccount"], - normalizeAccountId, - }); -} - export function resolveQaChannelAccount(params: { cfg: CoreConfig; accountId?: string | null; diff --git a/extensions/qqbot/src/bridge/config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts index 88b47bf84a71..ac219d8cf95e 100644 --- a/extensions/qqbot/src/bridge/config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -1,11 +1,8 @@ +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Qqbot helper module supports config shared behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - applyAccountNameToChannelSection, - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "openclaw/plugin-sdk/core"; +import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/core"; import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; import { describeAccount as engineDescribeAccount, @@ -61,46 +58,19 @@ function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) { return engineDescribeAccount(account as never); } -function formatQQBotAllowFrom(params: { - allowFrom: Array | undefined | null; -}): string[] { - return engineFormatAllowFrom(params.allowFrom); -} - export const qqbotConfigAdapter = { - listAccountIds: (cfg: OpenClawConfig) => listQQBotAccountIds(cfg), - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => - resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }), - defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultQQBotAccountId(cfg), - setAccountEnabled: ({ - cfg, - accountId, - enabled, - }: { - cfg: OpenClawConfig; - accountId: string; - enabled: boolean; - }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "qqbot", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "qqbot", - accountId, - clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"], - }), + ...createScopedChannelConfigAdapter({ + sectionKey: "qqbot", + listAccountIds: listQQBotAccountIds, + resolveAccount: (cfg, accountId) => + resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }), + defaultAccountId: resolveDefaultQQBotAccountId, + clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: engineFormatAllowFrom, + }), isConfigured: isQQBotConfigured, describeAccount: describeQQBotAccount, - resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }).config?.allowFrom, - formatAllowFrom: ({ allowFrom }: { allowFrom: Array | undefined | null }) => - formatQQBotAllowFrom({ allowFrom }), }; export const qqbotSetupAdapterShared = { diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index a82ca45d8369..243e26ad7982 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -9,7 +9,7 @@ import { } from "openclaw/plugin-sdk/json-schema-runtime"; import { DEFAULT_SECRET_FILE_MAX_BYTES } from "openclaw/plugin-sdk/secret-file-runtime"; import { describe, expect, it } from "vitest"; -import { qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./bridge/config-shared.js"; import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, @@ -122,6 +122,90 @@ describe("qqbot config", () => { expect(resolveDefaultQQBotAccountId(cfg)).toBe("bot2"); }); + it("keeps account mutations and allowlists scoped to the selected QQ Bot account", () => { + const cfg = { + channels: { + qqbot: { + appId: "default-app", + clientSecret: "default-secret", + accounts: { + work: { + appId: "work-app", + clientSecret: "work-secret", + allowFrom: ["qqbot:work-user"], + }, + }, + }, + }, + } as OpenClawConfig; + + expect(qqbotConfigAdapter.resolveAccount(cfg, "work")).toMatchObject({ + accountId: "work", + appId: "work-app", + clientSecret: "work-secret", + }); + expect(qqbotConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([ + "qqbot:work-user", + ]); + expect( + qqbotConfigAdapter.formatAllowFrom?.({ + cfg, + accountId: "work", + allowFrom: ["qqbot:work-user", 42], + }), + ).toEqual(["WORK-USER", "42"]); + expect( + qqbotConfigAdapter.setAccountEnabled?.({ + cfg, + accountId: "work", + enabled: false, + }), + ).toMatchObject({ + channels: { + qqbot: { + appId: "default-app", + accounts: { work: { appId: "work-app", enabled: false } }, + }, + }, + }); + }); + + it("clears default QQ Bot credentials without deleting named accounts", () => { + const cfg = { + channels: { + qqbot: { + appId: "default-app", + clientSecret: "default-secret", + clientSecretFile: "/tmp/default-qq-secret", + name: "Default bot", + accounts: { + work: { + appId: "work-app", + clientSecret: "work-secret", + }, + }, + }, + }, + } as OpenClawConfig; + + expect(qqbotConfigAdapter.deleteAccount?.({ cfg, accountId: "default" })).toMatchObject({ + channels: { + qqbot: { + appId: undefined, + clientSecret: undefined, + clientSecretFile: undefined, + name: undefined, + accounts: { + work: { + appId: "work-app", + clientSecret: "work-secret", + }, + }, + }, + }, + }); + }); + it("accepts SecretRef-backed credentials in the runtime schema", () => { const parsed = requireRuntimeSchema().safeParse({ defaultAccount: "bot2", diff --git a/extensions/raft/src/accounts.ts b/extensions/raft/src/accounts.ts index 3cdce3f2c9a2..dc4d2adf0e19 100644 --- a/extensions/raft/src/accounts.ts +++ b/extensions/raft/src/accounts.ts @@ -1,8 +1,5 @@ // Raft account resolution keeps CLI profiles scoped to their channel account. -import { - createAccountListHelpers, - resolveMergedAccountConfig, -} from "openclaw/plugin-sdk/account-helpers"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -25,8 +22,13 @@ export type ResolvedRaftAccount = { profile: string | null; }; -const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers(RAFT_CHANNEL_ID, { +const { + listAccountIds, + resolveDefaultAccountId, + resolveAccountConfig: resolveMergedRaftAccountConfig, +} = createAccountListHelpers(RAFT_CHANNEL_ID, { normalizeAccountId, + omitKeys: ["defaultAccount"], implicitDefaultAccount: { channelKeys: ["profile"], envVars: ["RAFT_PROFILE"], @@ -46,13 +48,7 @@ export function resolveRaftAccount(params: { }): ResolvedRaftAccount { const accountId = normalizeAccountId(params.accountId ?? resolveDefaultRaftAccountId(params.cfg)); const channel = resolveRaftConfig(params.cfg); - const merged = resolveMergedAccountConfig({ - channelConfig: channel, - accounts: channel?.accounts, - accountId, - omitKeys: ["defaultAccount"], - normalizeAccountId, - }); + const merged = resolveMergedRaftAccountConfig(params.cfg, accountId); const configuredProfile = normalizeOptionalString(merged.profile); const envProfile = accountId === DEFAULT_ACCOUNT_ID diff --git a/extensions/sms/src/accounts.ts b/extensions/sms/src/accounts.ts index 09f918ab9e5a..267c3fb611a5 100644 --- a/extensions/sms/src/accounts.ts +++ b/extensions/sms/src/accounts.ts @@ -1,12 +1,10 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Sms plugin module implements accounts behavior. import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; import { DEFAULT_ACCOUNT_ID, hasConfiguredAccountValue, - listCombinedAccountIds, resolveAccountEntry, - resolveListedDefaultAccountId, - resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; @@ -68,21 +66,17 @@ function hasBaseAccount(channelCfg: SmsChannelConfig | undefined): boolean { ); } -export function listSmsAccountIds(cfg: OpenClawConfig): string[] { - const channelCfg = getChannelConfig(cfg); - return listCombinedAccountIds({ - configuredAccountIds: Object.keys(channelCfg?.accounts ?? {}), - implicitAccountId: hasBaseAccount(channelCfg) ? DEFAULT_ACCOUNT_ID : undefined, - }); -} +const { + listAccountIds: listSmsAccountIds, + resolveDefaultAccountId: resolveDefaultSmsAccountId, + resolveAccountConfig: resolveMergedSmsAccountConfig, +} = createAccountListHelpers & SmsChannelConfig>(CHANNEL_ID, { + fallbackAccountIdWhenEmpty: false, + hasImplicitDefaultAccount: (cfg) => hasBaseAccount(getChannelConfig(cfg)), + omitKeys: ["defaultAccount"], +}); -export function resolveDefaultSmsAccountId(cfg: OpenClawConfig): string { - const channelCfg = getChannelConfig(cfg); - return resolveListedDefaultAccountId({ - accountIds: listSmsAccountIds(cfg), - configuredDefaultAccountId: normalizeOptionalAccountId(channelCfg?.defaultAccount), - }); -} +export { listSmsAccountIds, resolveDefaultSmsAccountId }; export function resolveSmsAccount( cfg: OpenClawConfig, @@ -91,23 +85,7 @@ export function resolveSmsAccount( const channelCfg = getChannelConfig(cfg) ?? {}; const id = normalizeOptionalAccountId(accountId) ?? resolveDefaultSmsAccountId(cfg); const accountConfig = resolveAccountEntry(channelCfg.accounts, id); - const channelConfig: Record & SmsChannelConfig = { ...channelCfg }; - const accountEntries: - | Record & SmsChannelConfig>> - | undefined = channelCfg.accounts - ? Object.fromEntries( - Object.entries(channelCfg.accounts).map(([accountKey, account]) => [ - accountKey, - { ...account }, - ]), - ) - : undefined; - const merged = resolveMergedAccountConfig & SmsChannelConfig>({ - channelConfig, - accounts: accountEntries, - accountId: id, - omitKeys: ["defaultAccount"], - }); + const merged = resolveMergedSmsAccountConfig(cfg, id); const useEnvFallbacks = id === DEFAULT_ACCOUNT_ID; const envAccountSid = useEnvFallbacks ? process.env.TWILIO_ACCOUNT_SID : undefined; diff --git a/extensions/synology-chat/src/accounts.ts b/extensions/synology-chat/src/accounts.ts index 5857a98dc453..e8c7edb167d5 100644 --- a/extensions/synology-chat/src/accounts.ts +++ b/extensions/synology-chat/src/accounts.ts @@ -3,11 +3,10 @@ * merges per-account overrides, falls back to environment variables. */ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, hasConfiguredAccountValue, - listCombinedAccountIds, - resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; @@ -27,12 +26,20 @@ function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig | unde return cfg?.channels?.["synology-chat"] as SynologyChatChannelConfig | undefined; } -function resolveImplicitAccountId(channelCfg: SynologyChatChannelConfig): string | undefined { - return hasConfiguredAccountValue(channelCfg.token) || - hasConfiguredAccountValue(process.env.SYNOLOGY_CHAT_TOKEN) - ? DEFAULT_ACCOUNT_ID - : undefined; -} +const { listAccountIds, resolveAccountConfig: resolveMergedSynologyChatAccountConfig } = + createAccountListHelpers & SynologyChatChannelConfig>("synology-chat", { + fallbackAccountIdWhenEmpty: false, + hasImplicitDefaultAccount: (cfg) => { + const channel = getChannelConfig(cfg); + return Boolean( + channel && + (hasConfiguredAccountValue(channel.token) || + hasConfiguredAccountValue(process.env.SYNOLOGY_CHAT_TOKEN)), + ); + }, + }); + +export { listAccountIds }; function getRawAccountConfig( channelCfg: SynologyChatChannelConfig, @@ -92,22 +99,6 @@ function parseRateLimitPerMinute(raw: string | undefined): number { return normalizeRateLimitPerMinuteValue(raw) ?? 30; } -/** - * List all configured account IDs for this channel. - * Returns ["default"] if there's a base config, plus any named accounts. - */ -export function listAccountIds(cfg: OpenClawConfig): string[] { - const channelCfg = getChannelConfig(cfg); - if (!channelCfg) { - return []; - } - - return listCombinedAccountIds({ - configuredAccountIds: Object.keys(channelCfg.accounts ?? {}), - implicitAccountId: resolveImplicitAccountId(channelCfg), - }); -} - /** * Resolve a specific account by ID with full defaults applied. * Falls back to env vars for the "default" account. @@ -121,13 +112,7 @@ export function resolveAccount( const accountOverrides = id === DEFAULT_ACCOUNT_ID ? undefined : (channelCfg.accounts?.[id] ?? undefined); const rawAccount = getRawAccountConfig(channelCfg, id); - const merged = resolveMergedAccountConfig & SynologyChatChannelConfig>({ - channelConfig: channelCfg as Record & SynologyChatChannelConfig, - accounts: channelCfg.accounts as - | Record & SynologyChatChannelConfig>> - | undefined, - accountId: id, - }); + const merged = resolveMergedSynologyChatAccountConfig(cfg, id); // Env var fallbacks (primarily for the "default" account) const envToken = normalizeOptionalString(process.env.SYNOLOGY_CHAT_TOKEN) ?? ""; diff --git a/extensions/synology-chat/src/core.test.ts b/extensions/synology-chat/src/core.test.ts index 6ab2994c0bae..e5b0dea0df39 100644 --- a/extensions/synology-chat/src/core.test.ts +++ b/extensions/synology-chat/src/core.test.ts @@ -170,6 +170,13 @@ describe("synology-chat account resolution", () => { expect(listAccountIds({ channels: {} })).toStrictEqual([]); }); + it("does not discover an env account when the channel is not installed", () => { + process.env.SYNOLOGY_CHAT_TOKEN = "env-token"; + + expect(listAccountIds({})).toStrictEqual([]); + expect(listAccountIds({ channels: {} })).toStrictEqual([]); + }); + it("lists the default account when base config has a token", () => { const cfg = { channels: { "synology-chat": { token: "abc" } } }; expect(listAccountIds(cfg)).toEqual(["default"]); diff --git a/extensions/telegram/src/account-selection.ts b/extensions/telegram/src/account-selection.ts index aa0d7b60a8ae..172edd8977be 100644 --- a/extensions/telegram/src/account-selection.ts +++ b/extensions/telegram/src/account-selection.ts @@ -1,6 +1,7 @@ // Telegram plugin module implements account selection behavior. import { - listCombinedAccountIds, + createAccountListHelpers, + hasConfiguredAccountValue, resolveListedDefaultAccountId, } from "openclaw/plugin-sdk/account-core"; import { @@ -32,16 +33,6 @@ function resolveDefaultAgentId(cfg: OpenClawConfig): string { return normalizeAgentId(chosen); } -function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { - if (key) { - ids.add(normalizeAccountId(key)); - } - } - return [...ids]; -} - function resolveBindingAccount(params: { binding: unknown; channelId: string; @@ -88,33 +79,25 @@ function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: strin return null; } -function hasConfiguredDefaultAccountValue(value: unknown): boolean { - if (typeof value === "string") { - return value.trim().length > 0; - } - return value !== undefined && value !== null; -} - function hasImplicitDefaultTelegramAccount(cfg: OpenClawConfig): boolean { const telegram = cfg.channels?.telegram; if (!telegram) { return false; } return ( - hasConfiguredDefaultAccountValue(telegram.botToken) || - hasConfiguredDefaultAccountValue(telegram.tokenFile) || - hasConfiguredDefaultAccountValue(process.env.TELEGRAM_BOT_TOKEN) + hasConfiguredAccountValue(telegram.botToken) || + hasConfiguredAccountValue(telegram.tokenFile) || + hasConfiguredAccountValue(process.env.TELEGRAM_BOT_TOKEN) ); } -export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { - return listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds(cfg), - additionalAccountIds: listBoundAccountIds(cfg, "telegram"), - implicitAccountId: hasImplicitDefaultTelegramAccount(cfg) ? DEFAULT_ACCOUNT_ID : undefined, - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, - }); -} +const { listAccountIds: listTelegramAccountIds } = createAccountListHelpers("telegram", { + normalizeAccountId, + additionalAccountIds: (cfg) => listBoundAccountIds(cfg, "telegram"), + hasImplicitDefaultAccount: hasImplicitDefaultTelegramAccount, +}); + +export { listTelegramAccountIds }; export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): { accountId: string; diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 1f0a042b0e07..e0eafe3c9cff 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -149,6 +149,28 @@ describe("resolveTelegramAccount", () => { expect(accounts[0]?.token).toBe("tok-work"); }); + it("preserves normalized agent-bound accounts and default-agent selection", () => { + const cfg = { + agents: { list: [{ id: "primary", default: true }] }, + channels: { + telegram: { + botToken: "tok-default", + accounts: { Alerts: { botToken: "tok-alerts" } }, + }, + }, + bindings: [ + { agentId: "primary", match: { channel: "telegram", accountId: " Ops Team " } }, + { agentId: "another", match: { channel: "telegram", accountId: "ops-team" } }, + { agentId: "ignored", match: { channel: "telegram", accountId: "*" } }, + { agentId: "ignored", match: { channel: "slack", accountId: "slack-only" } }, + ], + } as unknown as OpenClawConfig; + + expect(listTelegramAccountIds(cfg)).toEqual(["alerts", "default", "ops-team"]); + expect(resolveDefaultTelegramAccountId(cfg)).toBe("ops-team"); + expectNoMissingDefaultWarning(); + }); + it("keeps the implicit default account when named accounts are added to top-level credentials (#82780)", () => { const cfg = { channels: { diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts index dae184c2baaf..cf4ec5824e8a 100644 --- a/extensions/tlon/src/core.test.ts +++ b/extensions/tlon/src/core.test.ts @@ -228,6 +228,11 @@ describe("tlon core", () => { expect(resolved.error.message).toMatch(/invalid tlon target/i); }); + it("does not invent an account when the Tlon channel is unconfigured", () => { + expect(listTlonAccountIds({} as OpenClawConfig)).toEqual([]); + expect(listTlonAccountIds({ channels: { tlon: {} } } as OpenClawConfig)).toEqual([]); + }); + it("lists named accounts and the implicit default account", () => { const cfg = { channels: { diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index cb5d2e3e9346..d8115c4a0c51 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -1,10 +1,6 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Tlon type declarations define plugin contracts. -import { - DEFAULT_ACCOUNT_ID, - listCombinedAccountIds, - normalizeAccountId, - resolveMergedAccountConfig, -} from "openclaw/plugin-sdk/account-resolution"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { ResolvedChannelImplicitMentions } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { @@ -60,6 +56,17 @@ function resolveTlonChannelConfig(cfg: OpenClawConfig): TlonAccountConfig | unde return cfg.channels?.tlon as TlonAccountConfig | undefined; } +const { + listAccountIds: listTlonAccountIds, + resolveAccountConfig: resolveMergedNamedTlonAccountConfig, +} = createAccountListHelpers("tlon", { + normalizeAccountId, + fallbackAccountIdWhenEmpty: false, + hasImplicitDefaultAccount: (cfg) => Boolean(resolveTlonChannelConfig(cfg)?.ship), +}); + +export { listTlonAccountIds }; + function resolveMergedTlonAccountConfig( cfg: OpenClawConfig, accountId: string, @@ -68,14 +75,8 @@ function resolveMergedTlonAccountConfig( if (accountId === DEFAULT_ACCOUNT_ID) { return (channel ?? {}) as Record & TlonAccountConfig; } - return resolveMergedAccountConfig & TlonAccountConfig>({ - channelConfig: (channel ?? {}) as Record & TlonAccountConfig, - accounts: channel?.accounts as - | Record & TlonAccountConfig>> - | undefined, - accountId, - normalizeAccountId, - }); + return resolveMergedNamedTlonAccountConfig(cfg, accountId) as Record & + TlonAccountConfig; } export function resolveTlonAccount( @@ -150,14 +151,3 @@ export function resolveTlonAccount( ownerShip, }; } - -export function listTlonAccountIds(cfg: OpenClawConfig): string[] { - const base = resolveTlonChannelConfig(cfg); - if (!base) { - return []; - } - return listCombinedAccountIds({ - configuredAccountIds: Object.keys(base.accounts ?? {}).map(normalizeAccountId), - implicitAccountId: base.ship ? DEFAULT_ACCOUNT_ID : undefined, - }); -} diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 7f1ef0ca2ff6..96e671c670da 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; import { getAccountConfig, - listAccountIds, resolveDefaultTwitchAccountId, resolveTwitchAccountContext, + twitchConfigAdapter, } from "./config.js"; +const { listAccountIds } = twitchConfigAdapter; + describe("getAccountConfig", () => { const mockMultiAccountConfig = { channels: { @@ -117,6 +119,10 @@ describe("getAccountConfig", () => { }); describe("listAccountIds", () => { + it("does not invent a default account when Twitch is unconfigured", () => { + expect(listAccountIds({})).toEqual([]); + }); + it("includes the implicit default account from simplified config", () => { expect( listAccountIds({ @@ -160,6 +166,18 @@ describe("listAccountIds", () => { } as Parameters[0]), ).toEqual(["alerts-31m", "secondary"]); }); + + it("preserves an explicitly present empty root credential as an implicit account", () => { + expect( + listAccountIds({ + channels: { + twitch: { + username: "", + }, + }, + } as Parameters[0]), + ).toEqual(["default"]); + }); }); describe("resolveDefaultTwitchAccountId", () => { diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index 1bc1a669ce83..52edd5d423f7 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,6 +1,7 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Twitch helper module supports config behavior. import { - listCombinedAccountIds, + DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveNormalizedAccountEntry, } from "openclaw/plugin-sdk/account-resolution"; @@ -9,10 +10,25 @@ import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js"; import type { TwitchAccountConfig } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; -/** - * Default account ID for Twitch - */ -export const DEFAULT_ACCOUNT_ID = "default"; +export { DEFAULT_ACCOUNT_ID }; + +export type ResolvedTwitchAccount = TwitchAccountConfig & { accountId: string }; + +const { listAccountIds, resolveDefaultAccountId: resolveDefaultTwitchAccountId } = + createAccountListHelpers("twitch", { + normalizeAccountId, + fallbackAccountIdWhenEmpty: false, + hasImplicitDefaultAccount: (cfg) => { + const twitch = cfg.channels?.twitch as Record | undefined; + return ( + typeof twitch?.username === "string" || + typeof twitch?.accessToken === "string" || + typeof twitch?.channel === "string" + ); + }, + }); + +export { resolveDefaultTwitchAccountId }; type ResolvedTwitchAccountContext = { accountId: string; @@ -105,48 +121,6 @@ export function getAccountConfig( return account; } -/** - * List all configured account IDs - * - * Includes both explicit accounts and implicit "default" from base-level config - */ -export function listAccountIds(cfg: OpenClawConfig): string[] { - const twitch = cfg.channels?.twitch; - // Access accounts via unknown to handle union type (single-account vs multi-account) - const twitchRaw = twitch as Record | undefined; - const accountMap = twitchRaw?.accounts as Record | undefined; - - // Add implicit "default" if base-level config exists and "default" not already present - const hasBaseLevelConfig = - twitchRaw && - (typeof twitchRaw.username === "string" || - typeof twitchRaw.accessToken === "string" || - typeof twitchRaw.channel === "string"); - - return listCombinedAccountIds({ - configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) => - normalizeAccountId(accountId), - ), - implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined, - }); -} - -export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string { - const preferredRaw = - typeof cfg.channels?.twitch?.defaultAccount === "string" - ? cfg.channels.twitch.defaultAccount.trim() - : ""; - const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : ""; - const ids = listAccountIds(cfg); - if (preferred && ids.includes(preferred)) { - return preferred; - } - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - export function resolveTwitchAccountContext( cfg: OpenClawConfig, accountId?: string | null, @@ -165,6 +139,35 @@ export function resolveTwitchAccountContext( }; } +/** Keep runtime and setup on the same normalized, account-scoped credential path. */ +function resolveTwitchAccount( + cfg: OpenClawConfig, + accountId?: string | null, +): ResolvedTwitchAccount { + const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg)); + const account = getAccountConfig(cfg, resolvedAccountId); + return account + ? { accountId: resolvedAccountId, ...account } + : { + accountId: resolvedAccountId, + username: "", + accessToken: "", + clientId: "", + channel: "", + enabled: false, + }; +} + +/** Share account selection and configured-state checks across both Twitch entrypoints. */ +export const twitchConfigAdapter = { + listAccountIds, + resolveAccount: resolveTwitchAccount, + defaultAccountId: resolveDefaultTwitchAccountId, + isConfigured: (account: ResolvedTwitchAccount, cfg: OpenClawConfig) => + resolveTwitchAccountContext(cfg, account.accountId).configured, + isEnabled: (account: ResolvedTwitchAccount | undefined) => account?.enabled !== false, +}; + export function resolveTwitchSnapshotAccountId( cfg: OpenClawConfig, account: TwitchAccountConfig, diff --git a/extensions/twitch/src/plugin.test.ts b/extensions/twitch/src/plugin.test.ts index 28ffc41d4433..bfe68228fb92 100644 --- a/extensions/twitch/src/plugin.test.ts +++ b/extensions/twitch/src/plugin.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../api.js"; import { twitchPlugin } from "./plugin.js"; +import { twitchSetupPlugin } from "./setup-surface.js"; describe("twitchPlugin pairing", () => { it("normalizes trimmed twitch user prefixes in allow entries", () => { @@ -71,7 +72,7 @@ describe("twitchPlugin.status.buildAccountSnapshot", () => { } as OpenClawConfig; const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({ - account: secondary, + account: twitchPlugin.config.resolveAccount(cfg, "secondary"), cfg, }); @@ -108,4 +109,72 @@ describe("twitchPlugin.config", () => { expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary"); expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary"); }); + + it.each([ + { + name: "does not borrow the configured default account's token", + defaultToken: "oauth:default-token", + selectedToken: "", + expected: false, + }, + { + name: "does not reject a configured named account because the default lacks a token", + defaultToken: "", + selectedToken: "oauth:secondary-token", + expected: true, + }, + ])("$name", ({ defaultToken, selectedToken, expected }) => { + const cfg = { + channels: { + twitch: { + accounts: { + default: { + username: "default", + accessToken: defaultToken, + clientId: "default-client", + channel: "default-channel", + }, + secondary: { + username: "secondary", + accessToken: selectedToken, + clientId: "secondary-client", + channel: "secondary-channel", + }, + }, + }, + }, + } as OpenClawConfig; + + for (const config of [twitchPlugin.config, twitchSetupPlugin.config]) { + const account = config.resolveAccount(cfg, "secondary"); + expect(account.accountId).toBe("secondary"); + expect(config.isConfigured?.(account, cfg)).toBe(expected); + } + }); + + it("normalizes named accounts consistently across runtime and setup", () => { + const cfg = { + channels: { + twitch: { + accounts: { + Secondary: { + username: "secondary", + accessToken: "oauth:secondary-token", + clientId: "secondary-client", + channel: "secondary-channel", + }, + }, + }, + }, + } as OpenClawConfig; + + for (const config of [twitchPlugin.config, twitchSetupPlugin.config]) { + const account = config.resolveAccount(cfg, "SECONDARY\r\n"); + expect(account).toMatchObject({ + accountId: "secondary", + username: "secondary", + }); + expect(config.isConfigured?.(account, cfg)).toBe(true); + } + }); }); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 740419a8a849..949f18feb9f0 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -29,10 +29,11 @@ import { TwitchConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig, - listAccountIds, resolveDefaultTwitchAccountId, resolveTwitchAccountContext, resolveTwitchSnapshotAccountId, + twitchConfigAdapter, + type ResolvedTwitchAccount, } from "./config.js"; import { twitchMessageAdapter, twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; @@ -48,8 +49,6 @@ import type { } from "./types.js"; import { isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js"; -type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null }; - function normalizeTwitchMessagingTarget(target: string): string { const providerTarget = stripChannelTargetPrefix(target, "twitch", "twitch-chat"); const kindMatch = /^(user|dm|channel|group|conversation|room):/i.exec(providerTarget); @@ -117,30 +116,7 @@ export const twitchPlugin: ChannelPlugin = message: twitchMessageAdapter, configSchema: buildChannelConfigSchema(TwitchConfigSchema), config: { - listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg), - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => { - const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg); - const account = getAccountConfig(cfg, resolvedAccountId); - if (!account) { - return { - accountId: resolvedAccountId, - channel: "", - username: "", - accessToken: "", - clientId: "", - enabled: false, - }; - } - return { - accountId: resolvedAccountId, - ...account, - }; - }, - defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg), - isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean => - resolveTwitchAccountContext(cfg).configured, - isEnabled: (account: ResolvedTwitchAccount | undefined): boolean => - account?.enabled !== false, + ...twitchConfigAdapter, describeAccount: (account: TwitchAccountConfig | undefined) => account ? describeAccountSnapshot({ diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 7e883ef3a48b..b7aa2e727b9a 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -20,9 +20,10 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtim import { DEFAULT_ACCOUNT_ID, getAccountConfig, - listAccountIds, resolveDefaultTwitchAccountId, resolveTwitchAccountContext, + twitchConfigAdapter, + type ResolvedTwitchAccount, } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; @@ -493,38 +494,13 @@ export const twitchSetupWizard: ChannelSetupWizard = { disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; -type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null }; - export const twitchSetupPlugin: ChannelPlugin = { id: channel, meta: getChatChannelMeta(channel), capabilities: { chatTypes: ["group"], }, - config: { - listAccountIds: (cfg) => listAccountIds(cfg), - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg)); - const account = getAccountConfig(cfg, resolvedAccountId); - if (!account) { - return { - accountId: resolvedAccountId, - username: "", - accessToken: "", - clientId: "", - channel: "", - enabled: false, - }; - } - return { - accountId: resolvedAccountId, - ...account, - }; - }, - defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg), - isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured, - isEnabled: (account) => account.enabled !== false, - }, + config: twitchConfigAdapter, setup: twitchSetupAdapter, setupContract: twitchSetupContract, setupWizard: twitchSetupWizard, diff --git a/extensions/zalo/src/accounts.ts b/extensions/zalo/src/accounts.ts index 09a37d426a73..631a4530a3bc 100644 --- a/extensions/zalo/src/accounts.ts +++ b/extensions/zalo/src/accounts.ts @@ -1,8 +1,5 @@ // Zalo plugin module implements accounts behavior. -import { - createAccountListHelpers, - resolveMergedAccountConfig, -} from "openclaw/plugin-sdk/account-helpers"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -11,26 +8,19 @@ import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types export type { ResolvedZaloAccount }; -const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } = - createAccountListHelpers("zalo", { - implicitDefaultAccount: { - channelKeys: ["botToken", "tokenFile"], - envVars: ["ZALO_BOT_TOKEN"], - }, - }); +const { + listAccountIds: listZaloAccountIds, + resolveDefaultAccountId: resolveDefaultZaloAccountId, + resolveAccountConfig: mergeZaloAccountConfig, +} = createAccountListHelpers("zalo", { + omitKeys: ["defaultAccount"], + implicitDefaultAccount: { + channelKeys: ["botToken", "tokenFile"], + envVars: ["ZALO_BOT_TOKEN"], + }, +}); export { listZaloAccountIds, resolveDefaultZaloAccountId }; -function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.zalo as ZaloAccountConfig | undefined, - accounts: (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts as - | Record> - | undefined, - accountId, - omitKeys: ["defaultAccount"], - }); -} - export function resolveZaloAccount(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 4646b6a87eb2..daa72ad968a2 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -1,10 +1,6 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; // Zalouser plugin module implements accounts behavior. -import { - createAccountListHelpers, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - resolveMergedAccountConfig, -} from "openclaw/plugin-sdk/account-resolution"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -15,7 +11,9 @@ const loadZalouserAccountsRuntime = createLazyRuntimeModule(() => import("./acco const { listAccountIds: listZalouserAccountIds, resolveDefaultAccountId: resolveDefaultZalouserAccountId, -} = createAccountListHelpers("zalouser", { + resolveAccountConfig: resolveMergedZalouserAccountConfig, +} = createAccountListHelpers("zalouser", { + omitKeys: ["defaultAccount"], implicitDefaultAccount: { channelKeys: ["profile"], envVars: ["ZALOUSER_PROFILE", "ZCA_PROFILE"], @@ -24,14 +22,7 @@ const { export { listZalouserAccountIds, resolveDefaultZalouserAccountId }; function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): ZalouserAccountConfig { - const merged = resolveMergedAccountConfig({ - channelConfig: cfg.channels?.zalouser as ZalouserAccountConfig | undefined, - accounts: (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts as - | Record> - | undefined, - accountId, - omitKeys: ["defaultAccount"], - }); + const merged = resolveMergedZalouserAccountConfig(cfg, accountId); return { ...merged, // Match Telegram's safe default: groups stay allowlisted unless explicitly opened. diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 9bb51e901e04..f733123532d7 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -158,6 +158,53 @@ describe("createAccountListHelpers", () => { } } }); + + it("does not synthesize an account when an owner disables the empty fallback", () => { + const helpers = createAccountListHelpers("testchannel", { + fallbackAccountIdWhenEmpty: false, + implicitDefaultAccount: { channelKeys: ["token"] }, + }); + + expect(helpers.listAccountIds({} as OpenClawConfig)).toEqual([]); + expect(helpers.listAccountIds(cfg({}))).toEqual([]); + expect( + helpers.listAccountIds({ + channels: { testchannel: { token: "root-token" } }, + } as unknown as OpenClawConfig), + ).toEqual(["default"]); + expect(helpers.resolveDefaultAccountId({} as OpenClawConfig)).toBe("default"); + }); + + it("combines additional owner-discovered accounts without changing stable order", () => { + const helpers = createAccountListHelpers("testchannel", { + additionalAccountIds: () => ["bound", "work", "bound"], + }); + + expect(helpers.listAccountIds(cfg({ work: {}, alerts: {} }))).toEqual([ + "alerts", + "bound", + "work", + ]); + }); + + it("allows a single-account owner to name its configured implicit account", () => { + const helpers = createAccountListHelpers("testchannel", { + fallbackAccountIdWhenEmpty: false, + resolveImplicitAccountId: (config) => { + const channel = config.channels?.["testchannel"] as + | { token?: string; defaultAccount?: string } + | undefined; + return channel?.token ? (channel.defaultAccount ?? "default") : undefined; + }, + }); + + expect(helpers.listAccountIds({} as OpenClawConfig)).toEqual([]); + expect( + helpers.listAccountIds({ + channels: { testchannel: { token: "root-token", defaultAccount: "work" } }, + } as unknown as OpenClawConfig), + ).toEqual(["work"]); + }); }); describe("resolveDefaultAccountId", () => { @@ -208,6 +255,86 @@ describe("createAccountListHelpers", () => { }); }); +describe("createAccountListHelpers account resolution", () => { + type TestAccountConfig = { + enabled?: boolean; + defaultAccount?: string; + name?: string; + token?: string | { source: "env"; provider: string; id: string }; + commands?: { native?: boolean; callbackPath?: string }; + accounts?: Record>; + }; + + const resolver = createAccountListHelpers("testchannel", { + normalizeAccountId, + omitKeys: ["defaultAccount"], + nestedObjectKeys: ["commands"], + implicitDefaultAccount: { channelKeys: ["token"] }, + }); + + it("shares normalized account enumeration and configured default selection", () => { + const input = cfg({ "Work Team": { name: "Work" }, alerts: {} }, "Work Team"); + + expect(resolver.listConfiguredAccountIds(input)).toEqual(["work-team", "alerts"]); + expect(resolver.listAccountIds(input)).toEqual(["alerts", "work-team"]); + expect(resolver.resolveDefaultAccountId(input)).toBe("work-team"); + }); + + it("merges owner-declared nested fields while omitting account-selection metadata", () => { + const input = { + channels: { + testchannel: { + enabled: true, + defaultAccount: "Work Team", + commands: { native: true }, + accounts: { + "Work Team": { + name: "Work", + commands: { callbackPath: "/work" }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolver.resolveAccountConfig(input, "work-team")).toEqual({ + enabled: true, + name: "Work", + commands: { native: true, callbackPath: "/work" }, + }); + }); + + it("preserves unresolved SecretRef values without inspecting credentials", () => { + const token = { source: "env" as const, provider: "default", id: "TESTCHANNEL_TOKEN" }; + const input = { + channels: { + testchannel: { + accounts: { work: { token } }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolver.resolveAccountConfig(input, "work").token).toBe(token); + }); + + it("keeps a disabled root and explicit account overrides visible to their owner", () => { + const input = { + channels: { + testchannel: { + enabled: false, + accounts: { work: { enabled: true, name: "Work" } }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolver.resolveAccountConfig(input, "work")).toEqual({ + enabled: true, + name: "Work", + }); + expect(input.channels?.["testchannel"]).toMatchObject({ enabled: false }); + }); +}); + describe("listCombinedAccountIds", () => { it("combines configured, additional, and implicit ids once", () => { expect( diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 439fea95e49c..023a619447e1 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -18,18 +18,25 @@ import { import type { ChannelAccountSnapshot } from "./types.core.js"; /** - * Creates reusable account id listing and default-account resolution helpers for a channel. + * Creates reusable account listing, default selection, and merged config helpers for a channel. */ -export function createAccountListHelpers( +export function createAccountListHelpers< + TConfig extends Record = Record, +>( channelKey: string, options?: { normalizeAccountId?: (id: string) => string; + omitKeys?: Array<(keyof TConfig & string) | "defaultAccount">; + nestedObjectKeys?: Array; allowUnlistedDefaultAccount?: boolean; + additionalAccountIds?: (cfg: OpenClawConfig) => Iterable; + fallbackAccountIdWhenEmpty?: string | false; implicitDefaultAccount?: { channelKeys?: readonly string[]; envVars?: readonly string[]; }; hasImplicitDefaultAccount?: (cfg: OpenClawConfig) => boolean; + resolveImplicitAccountId?: (cfg: OpenClawConfig) => string | undefined; }, ) { function hasImplicitDefaultAccount(cfg: OpenClawConfig): boolean { @@ -88,8 +95,16 @@ export function createAccountListHelpers( function listAccountIds(cfg: OpenClawConfig): string[] { return listCombinedAccountIds({ configuredAccountIds: listConfiguredAccountIds(cfg), - implicitAccountId: hasImplicitDefaultAccount(cfg) ? DEFAULT_ACCOUNT_ID : undefined, - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, + additionalAccountIds: options?.additionalAccountIds?.(cfg), + implicitAccountId: options?.resolveImplicitAccountId + ? options.resolveImplicitAccountId(cfg) + : hasImplicitDefaultAccount(cfg) + ? DEFAULT_ACCOUNT_ID + : undefined, + fallbackAccountIdWhenEmpty: + options?.fallbackAccountIdWhenEmpty === false + ? undefined + : (options?.fallbackAccountIdWhenEmpty ?? DEFAULT_ACCOUNT_ID), }); } @@ -101,7 +116,27 @@ export function createAccountListHelpers( }); } - return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId }; + return { + listConfiguredAccountIds, + listAccountIds, + resolveDefaultAccountId, + // Channel owners destructure this resolver; an arrow keeps it independent of `this`. + resolveAccountConfig: (cfg: OpenClawConfig, accountId: string): TConfig => { + const channelConfig = cfg.channels?.[channelKey] as TConfig | undefined; + const accounts = ( + channelConfig as (TConfig & { accounts?: Record> }) | undefined + )?.accounts; + + return resolveMergedAccountConfig({ + channelConfig, + accounts, + accountId, + omitKeys: options?.omitKeys, + normalizeAccountId: options?.normalizeAccountId, + nestedObjectKeys: options?.nestedObjectKeys, + }); + }, + }; } /** diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 068d42a5ce58..840c749e865a 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -13,6 +13,7 @@ import { createEnvPatchedAccountSetupAdapter, createPatchedAccountSetupAdapter, moveSingleAccountChannelSectionToDefaultAccount, + patchScopedAccountConfig, prepareScopedSetupConfig, } from "./setup-helpers.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; @@ -203,6 +204,90 @@ describe("applySetupAccountConfigPatch", () => { }); }); +describe("patchScopedAccountConfig credential clearing", () => { + it("clears only default-account credential fields before applying their replacement", () => { + const next = patchScopedAccountConfig({ + cfg: asConfig({ + channels: { + "demo-setup": { + enabled: false, + token: "old-token", + tokenFile: "/old/token", + webhookPath: "/keep", + }, + }, + }), + channelKey: "demo-setup", + accountId: DEFAULT_ACCOUNT_ID, + clearFields: ["token", "tokenFile"], + patch: { token: "new-token" }, + ensureChannelEnabled: false, + }); + + expect(channelRecord(next, "demo-setup")).toEqual({ + enabled: false, + token: "new-token", + webhookPath: "/keep", + }); + }); + + it("clears only selected named-account credentials and preserves disabled siblings", () => { + const next = patchScopedAccountConfig({ + cfg: asConfig({ + channels: { + "demo-setup": { + enabled: false, + token: "root-token", + accounts: { + work: { enabled: false, token: "old-token", tokenFile: "/old/token" }, + alerts: { enabled: false, token: "alerts-token" }, + }, + }, + }, + }), + channelKey: "demo-setup", + accountId: "work", + clearFields: ["token", "tokenFile"], + patch: { token: "new-token" }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); + + const channel = channelRecord(next, "demo-setup"); + expect(channel.enabled).toBe(false); + expect(channel.token).toBe("root-token"); + expect(accountRecord(channel, "work")).toEqual({ enabled: false, token: "new-token" }); + expect(accountRecord(channel, "alerts")).toEqual({ + enabled: false, + token: "alerts-token", + }); + }); + + it("allows setup to explicitly re-enable an existing disabled named account", () => { + const next = patchScopedAccountConfig({ + cfg: asConfig({ + channels: { + "demo-setup": { + enabled: false, + accounts: { work: { enabled: false, tokenFile: "/old/token" } }, + }, + }, + }), + channelKey: "demo-setup", + accountId: "work", + patch: { token: "new-token" }, + accountPatch: { enabled: true, token: "new-token" }, + clearFields: ["tokenFile"], + ensureChannelEnabled: true, + ensureAccountEnabled: false, + }); + + const channel = channelRecord(next, "demo-setup"); + expect(channel.enabled).toBe(true); + expect(accountRecord(channel, "work")).toEqual({ enabled: true, token: "new-token" }); + }); +}); + describe("createPatchedAccountSetupAdapter", () => { it("stores default-account patch at channel root", () => { const adapter = createPatchedAccountSetupAdapter({ diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index fdd91dd79295..2b3f01a5ece6 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -295,6 +295,7 @@ export function patchScopedAccountConfig(params: { accountId: string; patch: Record; accountPatch?: Record; + clearFields?: readonly string[]; ensureChannelEnabled?: boolean; ensureAccountEnabled?: boolean; scopeDefaultToAccounts?: boolean; @@ -312,6 +313,16 @@ export function patchScopedAccountConfig(params: { const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled; const patch = params.patch; const accountPatch = params.accountPatch ?? patch; + const clearFields = (record: Record): Record => { + if (!params.clearFields?.length) { + return record; + } + const cleared = { ...record }; + for (const field of params.clearFields) { + delete cleared[field]; + } + return cleared; + }; if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) { // Default accounts historically live at channel root unless the channel opts into accounts.default. return { @@ -319,7 +330,7 @@ export function patchScopedAccountConfig(params: { channels: { ...params.cfg.channels, [params.channelKey]: { - ...base, + ...clearFields(base ?? {}), ...(ensureChannelEnabled ? { enabled: true } : {}), ...patch, }, @@ -328,7 +339,7 @@ export function patchScopedAccountConfig(params: { } const accounts = base?.accounts ?? {}; - const existingAccount = accounts[accountId] ?? {}; + const existingAccount = clearFields(accounts[accountId] ?? {}); // Preserve an explicit disabled account while enabling newly created accounts by default. return { ...params.cfg,