From e1d5d3dc16f0806c9e1ec1869894e940dd068e90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 06:48:30 -0700 Subject: [PATCH] refactor(channels): move single-account promotion keys to plugin declarations (#112293) * refactor(channels): move single-account promotion keys to plugin declarations * fix(channels): keep legacy promotion tier for undeclared setup adapters * fix(channels): keep plugin discovery lazy in setup promotion helpers * fix(channels): resolve bundled promotion surfaces from setup-only artifacts * test(matrix): use vi.stubEnv in setup test env helper --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-setup.md | 8 +- extensions/clickclack/src/setup-core.ts | 1 + extensions/imessage/package.json | 1 + extensions/imessage/src/setup-core.ts | 13 +- extensions/irc/package.json | 3 + extensions/irc/src/setup-core.ts | 1 + extensions/matrix/src/channel.setup.test.ts | 25 +- extensions/matrix/src/setup-contract.ts | 5 + extensions/matrix/src/setup-core.ts | 8 + extensions/nextcloud-talk/package.json | 3 + extensions/nextcloud-talk/src/setup-core.ts | 1 + extensions/signal/package.json | 3 + extensions/signal/src/setup-core.ts | 46 +-- extensions/slack/package.json | 3 + extensions/slack/src/setup-core.ts | 1 + extensions/telegram/src/setup-contract.ts | 2 +- extensions/telegram/src/setup-core.ts | 26 +- .../telegram/src/setup-surface.helpers.ts | 3 + extensions/telegram/src/setup-surface.test.ts | 9 +- extensions/telegram/src/setup-surface.ts | 2 + extensions/tlon/package.json | 3 + extensions/tlon/src/setup-core.ts | 1 + extensions/twitch/package.json | 3 + extensions/twitch/src/setup-surface.ts | 1 + extensions/whatsapp/package.json | 1 + extensions/whatsapp/src/channel.setup.test.ts | 6 + extensions/whatsapp/src/setup-core.ts | 1 + extensions/zalo/package.json | 3 + extensions/zalo/src/setup-core.ts | 41 +-- src/channels/plugins/setup-helpers.test.ts | 13 + src/channels/plugins/setup-helpers.ts | 51 +--- .../plugins/setup-promotion-bundled.ts | 17 ++ .../plugins/setup-promotion-helpers.test.ts | 274 ++++++++++++++++-- .../plugins/setup-promotion-helpers.ts | 150 +++++++--- src/channels/plugins/setup-promotion-keys.ts | 32 +- .../plugins/setup-wizard-helpers.test.ts | 11 +- src/channels/plugins/setup-wizard-helpers.ts | 8 +- src/channels/plugins/setup-wizard.ts | 4 + src/commands/channels/add.ts | 1 + src/commands/doctor-config-flow.test.ts | 205 ++++++++++--- .../doctor-legacy-config.migrations.test.ts | 98 +++++++ .../shared/legacy-config-core-normalizers.ts | 17 +- 43 files changed, 867 insertions(+), 242 deletions(-) create mode 100644 src/channels/plugins/setup-promotion-bundled.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0a4525e6533e..d9e351a1bb55 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -117,8 +117,8 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime 596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion 9d7d884330397701c7de9f5b6800b970b2b349027491704184cb1bd6cda1fd00 module/session-store-runtime -695971d31b3e16f0bf9b643acc30df312fe94b3c0dfb27a2e6156c8a00b5e261 module/setup -9129c17df1523903f34beffd348ce177fcaa2983e155a8c854a50cf1bd7e99c4 module/setup-runtime +5a8eb44c7fdf351aa3b4cd53db5c5233770faa8a56551a2eb3a55bd9d57c75cf module/setup +81a6e5696a540578df31e432e0894c9eb3734ba1f40d7a62943c2470f25ab12f module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools 18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-settings diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index ba4d66dfb645..355ac5ea2e91 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -354,12 +354,18 @@ The setup patch adapters stay hot-path safe on import. Their bundled single-acco When a channel upgrades from a single-account top-level config to `channels..accounts.*`, the default shared behavior moves promoted account-scoped values into `accounts.default`. -Bundled channels can narrow or override that promotion through their setup contract surface: +Every channel plugin can extend or narrow that promotion through its setup adapter: - `singleAccountKeysToMove`: extra top-level keys that should move into the promoted account - `namedAccountPromotionKeys`: when named accounts already exist, only these keys move into the promoted account; shared policy/delivery keys stay at the channel root - `resolveSingleAccountPromotionTarget(...)`: choose which existing account receives promoted values +The presence of `singleAccountKeysToMove` marks the promotion contract complete. Declare the field even when it is an empty array to opt out of legacy key promotion. Adapters that omit the field retain the pre-declaration promotion tiers for compatibility with already-published plugins; this compatibility tier is scheduled for removal at the next Plugin SDK major after the migration documented in [#112238](https://github.com/openclaw/openclaw/issues/112238). + +Declare `openclaw.setupFeatures.configPromotion: true` in the plugin package manifest when doctor must load these declarations from the lightweight bundled setup artifact. The setup-only plugin surface and the full channel plugin must expose the same declarations. + +When calling `moveSingleAccountChannelSectionToDefaultAccount(...)` with an already resolved plugin, pass its setup adapter as `setupSurface`. Caller-supplied setup surfaces take precedence over loaded and bundled lookup, which keeps scoped or setup-only plugins independent of global registration. + Matrix is the current bundled example. If exactly one named Matrix account already exists, or if `defaultAccount` points at an existing non-canonical key such as `Ops`, promotion preserves that account instead of creating a new `accounts.default` entry. diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 20bcbb02da3d..d479d3cec625 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -157,6 +157,7 @@ export function applyClickClackSetupConfigPatch(params: { : moveSingleAccountChannelSectionToDefaultAccount({ cfg: params.cfg, channelKey: channel, + setupSurface: clickClackSetupAdapter, }); const namedConfig = applyAccountNameToChannelSection({ cfg: scopedConfig, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 070823c83320..6fe2de25112a 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -16,6 +16,7 @@ ], "setupEntry": "./setup-entry.ts", "setupFeatures": { + "configPromotion": true, "legacyStateMigrations": true }, "channel": { diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 1bd9c62d2b04..6c0300429d42 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -135,6 +135,7 @@ async function promptIMessageAllowFrom(params: { channel, accountId, allowFrom, + setupSurface: imessageSetupAdapter, }), }); } @@ -180,6 +181,7 @@ export const imessageDmPolicy = { ), } : { dmPolicy: policy }, + setupSurface: imessageSetupAdapter, }); }, promptAllowFrom: promptIMessageAllowFrom, @@ -222,10 +224,13 @@ export const imessageCompletionNote = { ], }; -export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, - buildPatch: (input) => buildIMessageSetupPatch(input), -}); +export const imessageSetupAdapter: ChannelSetupAdapter = { + ...createPatchedAccountSetupAdapter({ + channelKey: channel, + buildPatch: (input) => buildIMessageSetupPatch(input), + }), + singleAccountKeysToMove: ["cliPath", "dbPath", "service", "region"], +}; export const imessageSetupStatusBase = { configuredLabel: t("wizard.channels.statusConfigured"), diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 95b20667be62..c3197f5ccff4 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -18,6 +18,9 @@ "allowInvalidConfigRecovery": true }, "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "irc", "configuredState": { diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 1d32fab61ebb..a481309d7c5c 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -111,6 +111,7 @@ export function setIrcGroupAccess( } export const ircSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove: ["password"], resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index ca8942c34d24..f5aceca0299c 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -100,26 +100,13 @@ describe("matrix setup post-write bootstrap", () => { values: Record, run: () => Promise | T, ) { - const previousEnv = Object.fromEntries( - Object.keys(values).map((key) => [key, process.env[key]]), - ) as Record; for (const [key, value] of Object.entries(values)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } + vi.stubEnv(key, value); } try { return await run(); } finally { - for (const [key, value] of Object.entries(previousEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + vi.unstubAllEnvs(); } } @@ -131,6 +118,14 @@ describe("matrix setup post-write bootstrap", () => { installMatrixTestRuntime(); }); + it("exposes config-promotion declarations on the setup-only adapter", () => { + expect(matrixSetupAdapter.singleAccountKeysToMove).toEqual( + expect.arrayContaining(["homeserver", "accessToken", "deviceName", "rooms"]), + ); + expect(matrixSetupAdapter.namedAccountPromotionKeys).toContain("homeserver"); + expect(matrixSetupAdapter.resolveSingleAccountPromotionTarget).toBeTypeOf("function"); + }); + it("bootstraps verification for newly added encrypted accounts", async () => { const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig(); mockBootstrapResult({ success: true, backupVersion: "7" }); diff --git a/extensions/matrix/src/setup-contract.ts b/extensions/matrix/src/setup-contract.ts index d2c906315abe..7b6bc9e08dab 100644 --- a/extensions/matrix/src/setup-contract.ts +++ b/extensions/matrix/src/setup-contract.ts @@ -2,7 +2,12 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; export const matrixSingleAccountKeysToMove = [ + "homeserver", + "userId", + "accessToken", + "password", "deviceId", + "deviceName", "avatarUrl", "initialSyncLimit", "encryption", diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index f05d7b3ac5f9..01f623b909b5 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -11,6 +11,11 @@ import { import { resolveDefaultMatrixAccountId, resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js"; import { applyMatrixSetupAccountConfig, validateMatrixSetupInput } from "./setup-config.js"; +import { + namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, + singleAccountKeysToMove, +} from "./setup-contract.js"; import { resolveMatrixSetupDmAllowFrom } from "./setup-dm-policy.js"; import type { CoreConfig } from "./types.js"; @@ -111,6 +116,9 @@ export function createMatrixSetupWizardProxy( } export const matrixSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove, + namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, resolveAccountId: ({ accountId, input }) => resolveMatrixSetupAccountId({ accountId, diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index ff9611780e21..65325a652a58 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -24,6 +24,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "nextcloud-talk", "configuredState": { diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 97c677822a2d..349904f256b1 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -207,6 +207,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { }; export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove: ["rooms"], resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), prepareAccountConfigInput: ({ input }) => ({ ...input, diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 9ea73014edfe..6ea1b639fd0c 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -14,6 +14,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "signal", "approvalFlags": ["native"], diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 1543b5c41631..478a43102221 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -128,6 +128,7 @@ async function promptSignalAllowFrom(params: { channel, accountId, allowFrom, + setupSurface: signalSetupAdapter, }), }); } @@ -172,6 +173,7 @@ export const signalDmPolicy = { ), } : { dmPolicy: policy }, + setupSurface: signalSetupAdapter, }), promptAllowFrom: promptSignalAllowFrom, }; @@ -224,24 +226,34 @@ export const signalCompletionNote = { ], }; -export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, - validateInput: createSetupInputPresenceValidator({ - validate: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, +export const signalSetupAdapter: ChannelSetupAdapter = { + ...createPatchedAccountSetupAdapter({ + channelKey: channel, + validateInput: createSetupInputPresenceValidator({ + validate: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + }), + buildPatch: (input) => buildSignalSetupPatch(input), }), - buildPatch: (input) => buildSignalSetupPatch(input), -}); + singleAccountKeysToMove: [ + "signalNumber", + "account", + "cliPath", + "httpUrl", + "httpHost", + "httpPort", + ], +}; export function createSignalSetupWizardProxy(loadWizard: () => Promise) { return createDelegatedSetupWizardProxy({ diff --git a/extensions/slack/package.json b/extensions/slack/package.json index b2e87364bfaa..e0e2faa0f3a0 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -33,6 +33,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "slack", "configuredState": { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 91afa2aeb9a6..9d583a168585 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -252,6 +252,7 @@ const slackSetupAdapterBase = createPatchedAccountSetupAdapter({ export const slackSetupAdapter: ChannelSetupAdapter = { ...slackSetupAdapterBase, + singleAccountKeysToMove: ["appToken"], applyAccountConfig: ({ cfg, accountId, input }) => { const identity = input.identity ?? inspectSlackAccount({ cfg, accountId }).config.identity; return slackSetupAdapterBase.applyAccountConfig({ diff --git a/extensions/telegram/src/setup-contract.ts b/extensions/telegram/src/setup-contract.ts index 963a4bfd4fd1..053893dbd1c2 100644 --- a/extensions/telegram/src/setup-contract.ts +++ b/extensions/telegram/src/setup-contract.ts @@ -1,3 +1,3 @@ // Telegram plugin module implements setup contract behavior. -export const singleAccountKeysToMove = ["streaming"]; +export const singleAccountKeysToMove = ["streaming", "webhookSecret"]; export const namedAccountPromotionKeys = ["botToken", "tokenFile"] as const; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 02c78efe2a7d..e504b84cdfa7 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -12,6 +12,7 @@ import { import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { isNumericTelegramSenderUserId } from "./allow-from.js"; +import { namedAccountPromotionKeys, singleAccountKeysToMove } from "./setup-contract.js"; const t = createSetupTranslator(); @@ -86,14 +87,23 @@ export async function promptTelegramAllowFromForAccount(params: { channel, accountId, patch: { dmPolicy: "allowlist", allowFrom: unique }, + setupSurface: telegramSetupAdapter, }); } -export const telegramSetupAdapter: ChannelSetupAdapter = createEnvPatchedAccountSetupAdapter({ - channelKey: channel, - defaultAccountOnlyEnvError: "TELEGRAM_BOT_TOKEN can only be used for the default account.", - missingCredentialError: "Telegram requires token or --token-file (or --use-env).", - hasCredentials: (input) => Boolean(input.token || input.tokenFile), - buildPatch: (input) => - input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}, -}); +export const telegramSetupAdapter: ChannelSetupAdapter = { + ...createEnvPatchedAccountSetupAdapter({ + channelKey: channel, + defaultAccountOnlyEnvError: "TELEGRAM_BOT_TOKEN can only be used for the default account.", + missingCredentialError: "Telegram requires token or --token-file (or --use-env).", + hasCredentials: (input) => Boolean(input.token || input.tokenFile), + buildPatch: (input) => + input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}, + }), + singleAccountKeysToMove, + namedAccountPromotionKeys, +}; diff --git a/extensions/telegram/src/setup-surface.helpers.ts b/extensions/telegram/src/setup-surface.helpers.ts index cede352a10bb..21945cb793d7 100644 --- a/extensions/telegram/src/setup-surface.helpers.ts +++ b/extensions/telegram/src/setup-surface.helpers.ts @@ -15,6 +15,7 @@ import { resolveTelegramAccount, } from "./accounts.js"; import { promptTelegramAllowFromForAccount } from "./setup-core.js"; +import { telegramSetupAdapter } from "./setup-core.js"; const channel = "telegram" as const; @@ -40,6 +41,7 @@ export function ensureTelegramDefaultGroupMentionGate( }, }, }, + setupSurface: telegramSetupAdapter, }); } @@ -104,6 +106,7 @@ export const telegramSetupDmPolicy: ChannelSetupDmPolicy = { channel, accountId: resolvedAccountId, patch, + setupSurface: telegramSetupAdapter, }); }, promptAllowFrom: promptTelegramAllowFromForAccount, diff --git a/extensions/telegram/src/setup-surface.test.ts b/extensions/telegram/src/setup-surface.test.ts index 4ad533e75b52..3233eef2db9d 100644 --- a/extensions/telegram/src/setup-surface.test.ts +++ b/extensions/telegram/src/setup-surface.test.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { describe, expect, it, vi } from "vitest"; -import { promptTelegramAllowFromForAccount } from "./setup-core.js"; +import { promptTelegramAllowFromForAccount, telegramSetupAdapter } from "./setup-core.js"; import { buildTelegramDmAccessWarningLines, ensureTelegramDefaultGroupMentionGate, @@ -11,6 +11,13 @@ import { } from "./setup-surface.helpers.js"; import { telegramSetupWizard } from "./setup-surface.js"; +describe("Telegram setup promotion contract", () => { + it("exposes webhookSecret without widening named-account promotion", () => { + expect(telegramSetupAdapter.singleAccountKeysToMove).toEqual(["streaming", "webhookSecret"]); + expect(telegramSetupAdapter.namedAccountPromotionKeys).toEqual(["botToken", "tokenFile"]); + }); +}); + describe("ensureTelegramDefaultGroupMentionGate", () => { it('adds groups["*"].requireMention=true for fresh setups', () => { const cfg = ensureTelegramDefaultGroupMentionGate( diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index dd600b4a8235..5186f448d104 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -18,6 +18,7 @@ import { getTelegramTokenHelpLines, getTelegramUserIdHelpLines, parseTelegramAllowFromId, + telegramSetupAdapter, } from "./setup-core.js"; import { buildTelegramDmAccessWarningLines, @@ -94,6 +95,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { channel, accountId, patch: { dmPolicy: "allowlist", allowFrom }, + setupSurface: telegramSetupAdapter, }), }), finalize: async ({ cfg, accountId, prompter }) => { diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 1eedc0e99b0f..d122208f8793 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -31,6 +31,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "tlon", "label": "Tlon", diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index 75e7346fb677..bd0ca8595eeb 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -197,6 +197,7 @@ export function applyTlonSetupConfig(params: { } export const tlonSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove: ["url", "code"], resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), prepareAccountConfigInput: ({ input }) => { const url = normalizeOptionalString(input.url); diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 2fea52f35daa..51a971737a2d 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -21,6 +21,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "install": { "npmSpec": "@openclaw/twitch", "defaultChoice": "npm", diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 9a24aca6fe76..54e7dd679b9c 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -383,6 +383,7 @@ const twitchGroupAccess: NonNullable = { }; export const twitchSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove: ["accessToken"], resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg), applyAccountConfig: ({ cfg, accountId }) => setTwitchAccount( diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 86a932371025..b28f0ea291ab 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -30,6 +30,7 @@ ], "setupEntry": "./setup-entry.ts", "setupFeatures": { + "configPromotion": true, "legacyStateMigrations": true, "legacySessionSurfaces": true }, diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index cb2fcec01cc2..1d8e7aa1e65a 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -117,6 +117,12 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +describe("WhatsApp setup promotion contract", () => { + it("exposes authDir on the setup-only plugin surface", () => { + expect(whatsappSetupPlugin.setup?.singleAccountKeysToMove).toEqual(["authDir"]); + }); +}); + async function runConfigureWithHarness(params: { harness: ReturnType; cfg?: OpenClawConfig; diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index 7b31ee248f4f..d30cf2e75be4 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -9,6 +9,7 @@ import { const channel = "whatsapp" as const; export const whatsappSetupAdapter: ChannelSetupAdapter = { + singleAccountKeysToMove: ["authDir"], resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 44fe2c906351..60b1823aeaa7 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -24,6 +24,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "configPromotion": true + }, "channel": { "id": "zalo", "configuredState": { diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index a39f334dc7a9..385d65cef27a 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -23,26 +23,29 @@ type ZaloAccountSetupConfig = { allowFrom?: Array | ReadonlyArray; }; -export const zaloSetupAdapter = createPatchedAccountSetupAdapter({ - channelKey: channel, - validateInput: createSetupInputPresenceValidator({ - defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.", - whenNotUseEnv: [ - { - someOf: ["token", "tokenFile"], - message: "Zalo requires token or --token-file (or --use-env).", - }, - ], +export const zaloSetupAdapter = { + ...createPatchedAccountSetupAdapter({ + channelKey: channel, + validateInput: createSetupInputPresenceValidator({ + defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.", + whenNotUseEnv: [ + { + someOf: ["token", "tokenFile"], + message: "Zalo requires token or --token-file (or --use-env).", + }, + ], + }), + buildPatch: (input) => + input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}, }), - buildPatch: (input) => - input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}, -}); + singleAccountKeysToMove: ["webhookSecret", "tokenFile"], +}; export const zaloDmPolicy: ChannelSetupDmPolicy = { label: "Zalo", diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index bec9bdae2ca4..068d42a5ce58 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -15,6 +15,7 @@ import { moveSingleAccountChannelSectionToDefaultAccount, prepareScopedSetupConfig, } from "./setup-helpers.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; @@ -43,6 +44,9 @@ function accountRecord( } const matrixSingleAccountKeysToMove = [ + "homeserver", + "userId", + "accessToken", "allowBots", "deviceId", "deviceName", @@ -57,6 +61,12 @@ const matrixNamedAccountPromotionKeys = [ "userId", ] as const; const telegramSingleAccountKeysToMove = ["streaming"] as const; +const matrixSetupSurface = { + applyAccountConfig: ({ cfg }) => cfg, + singleAccountKeysToMove: matrixSingleAccountKeysToMove, + namedAccountPromotionKeys: matrixNamedAccountPromotionKeys, + resolveSingleAccountPromotionTarget: resolveMatrixSingleAccountPromotionTarget, +} as ChannelSetupAdapter; function collectNamedAccountIds(accounts: Record): string[] { const ids: string[] = []; @@ -282,6 +292,7 @@ describe("moveSingleAccountChannelSectionToDefaultAccount", () => { }, }), channelKey: "matrix", + setupSurface: matrixSetupSurface, }); const channel = channelRecord(next, "matrix"); @@ -310,6 +321,7 @@ describe("moveSingleAccountChannelSectionToDefaultAccount", () => { }, }), channelKey: "matrix", + setupSurface: matrixSetupSurface, }); const channel = channelRecord(next, "matrix"); @@ -365,6 +377,7 @@ describe("moveSingleAccountChannelSectionToDefaultAccount", () => { }, }), channelKey: "matrix", + setupSurface: matrixSetupSurface, }); const channel = channelRecord(next, "matrix"); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index d0043ca91cd5..9b81821dae7d 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -7,10 +7,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { z, type ZodType } from "zod"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; -import { - collectSingleAccountPromotionEntries, - isSetupSingleAccountPromotionKey, -} from "./setup-promotion-keys.js"; +import { resolveSingleAccountKeysToMove } from "./setup-promotion-helpers.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelSetupInput } from "./types.core.js"; @@ -20,22 +17,6 @@ type ChannelSectionBase = { accounts?: Record>; }; -const NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL: Record = { - matrix: [ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceId", - "deviceName", - "avatarUrl", - "initialSyncLimit", - "encryption", - ], - telegram: ["botToken", "tokenFile"], -}; - function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean { const channels = cfg.channels as Record | undefined; const base = channels?.[channelKey] as ChannelSectionBase | undefined; @@ -460,22 +441,16 @@ function resolveExistingAccountKey( return targetAccountId; } -function resolveSingleAccountKeysToMove(params: { - channelKey: string; - channel: Record; -}): string[] { - const { entries, hasNamedAccounts } = collectSingleAccountPromotionEntries(params.channel); - const keysToMove = entries.filter(isSetupSingleAccountPromotionKey); - if (!hasNamedAccounts || keysToMove.length === 0) { - return keysToMove; +function resolveSingleAccountPromotionTarget(params: { + channel: ChannelSectionBase; + setupSurface?: ChannelSetupAdapter; +}): string { + const pluginTarget = params.setupSurface?.resolveSingleAccountPromotionTarget?.({ + channel: params.channel, + }); + if (pluginTarget?.trim()) { + return normalizeAccountId(pluginTarget); } - const namedAccountPromotionKeys = NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL[params.channelKey]; - return namedAccountPromotionKeys - ? keysToMove.filter((key) => namedAccountPromotionKeys.includes(key)) - : keysToMove; -} - -function resolveSingleAccountPromotionTarget(params: { channel: ChannelSectionBase }): string { const accounts = params.channel.accounts ?? {}; const normalizedDefaultAccount = typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim() @@ -500,6 +475,7 @@ function resolveSingleAccountPromotionTarget(params: { channel: ChannelSectionBa export function moveSingleAccountChannelSectionToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; + setupSurface?: ChannelSetupAdapter; }): OpenClawConfig { const channels = params.cfg.channels as Record | undefined; const baseConfig = channels?.[params.channelKey]; @@ -514,6 +490,8 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const keysToMove = resolveSingleAccountKeysToMove({ channelKey: params.channelKey, channel: base, + setupSurface: params.setupSurface, + includeSetupKeys: true, }); if (keysToMove.length === 0) { return params.cfg; @@ -521,6 +499,7 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const targetAccountId = resolveSingleAccountPromotionTarget({ channel: base, + setupSurface: params.setupSurface, }); // Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape. const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId); @@ -537,6 +516,8 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const keysToMove = resolveSingleAccountKeysToMove({ channelKey: params.channelKey, channel: base, + setupSurface: params.setupSurface, + includeSetupKeys: true, }); return moveSingleAccountKeysIntoAccount({ cfg: params.cfg, diff --git a/src/channels/plugins/setup-promotion-bundled.ts b/src/channels/plugins/setup-promotion-bundled.ts new file mode 100644 index 000000000000..6ad7a03385a7 --- /dev/null +++ b/src/channels/plugins/setup-promotion-bundled.ts @@ -0,0 +1,17 @@ +/** + * Doctor-only bundled setup promotion surface lookup. + * + * Kept separate so hot Plugin SDK setup helpers never import bundled discovery. + */ +import { getBundledChannelSetupPlugin, hasBundledChannelPackageSetupFeature } from "./bundled.js"; +import type { ChannelSetupPromotionSurface } from "./setup-promotion-helpers.js"; + +export function resolveBundledChannelSetupPromotionSurface( + channelKey: string, +): ChannelSetupPromotionSurface | null { + if (!hasBundledChannelPackageSetupFeature(channelKey, "configPromotion")) { + return null; + } + const setup = getBundledChannelSetupPlugin(channelKey)?.setup; + return setup && typeof setup === "object" ? setup : null; +} diff --git a/src/channels/plugins/setup-promotion-helpers.test.ts b/src/channels/plugins/setup-promotion-helpers.test.ts index 2c46b8384f05..7452f25fd5b0 100644 --- a/src/channels/plugins/setup-promotion-helpers.test.ts +++ b/src/channels/plugins/setup-promotion-helpers.test.ts @@ -1,27 +1,87 @@ // Setup promotion helper tests cover setup-result promotion into configured channel state. import { beforeEach, describe, expect, it, vi } from "vitest"; -const getBundledChannelPluginMock = vi.hoisted(() => vi.fn()); -const hasBundledChannelPackageSetupFeatureMock = vi.hoisted(() => vi.fn()); const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); +const getBundledChannelPluginMock = vi.hoisted(() => vi.fn()); +const getBundledChannelSetupPluginMock = vi.hoisted(() => vi.fn()); +const hasBundledChannelPackageSetupFeatureMock = vi.hoisted(() => vi.fn()); +const resolveBundledSurfaceMock = vi.hoisted(() => vi.fn()); vi.mock("./bundled.js", () => ({ getBundledChannelPlugin: getBundledChannelPluginMock, + getBundledChannelSetupPlugin: getBundledChannelSetupPluginMock, hasBundledChannelPackageSetupFeature: hasBundledChannelPackageSetupFeatureMock, })); -vi.mock("./registry.js", () => ({ - getLoadedChannelPlugin: getLoadedChannelPluginMock, +vi.mock("./registry-loaded.js", () => ({ + getLoadedChannelPluginForRead: getLoadedChannelPluginMock, })); -import { resolveSingleAccountKeysToMove } from "./setup-promotion-helpers.js"; +import { resolveBundledChannelSetupPromotionSurface } from "./setup-promotion-bundled.js"; +import { + resolveSingleAccountKeysToMove, + resolveSingleAccountPromotion, +} from "./setup-promotion-helpers.js"; + +const legacyCommonKeys = [ + "appToken", + "account", + "signalNumber", + "authDir", + "cliPath", + "dbPath", + "httpUrl", + "httpHost", + "httpPort", + "webhookSecret", + "service", + "region", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "url", + "code", +] as const; +const legacySetupOnlyKeys = [ + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "threadReplies", + "startupVerification", + "startupVerificationCooldownHours", + "autoJoin", + "autoJoinAllowlist", + "rooms", +] as const; + +function valuesFor(keys: readonly string[]): Record { + return Object.fromEntries(keys.map((key) => [key, `value:${key}`])); +} describe("setup promotion helpers", () => { beforeEach(() => { getBundledChannelPluginMock.mockReset(); + getBundledChannelSetupPluginMock.mockReset(); hasBundledChannelPackageSetupFeatureMock.mockReset(); - hasBundledChannelPackageSetupFeatureMock.mockReturnValue(false); getLoadedChannelPluginMock.mockReset(); + resolveBundledSurfaceMock.mockReset(); + }); + + it("resolves bundled promotion from the setup-only plugin", () => { + hasBundledChannelPackageSetupFeatureMock.mockReturnValue(true); + getBundledChannelSetupPluginMock.mockReturnValue({ + setup: { singleAccountKeysToMove: ["customAuth"] }, + }); + + expect(resolveBundledChannelSetupPromotionSurface("demo")).toEqual({ + singleAccountKeysToMove: ["customAuth"], + }); + expect(getBundledChannelSetupPluginMock).toHaveBeenCalledWith("demo"); + expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); }); it("keeps static single-account migration keys cheap", () => { @@ -38,10 +98,178 @@ describe("setup promotion helpers", () => { expect(keys).toEqual(["dmPolicy", "allowFrom", "groupPolicy", "groupAllowFrom"]); expect(getLoadedChannelPluginMock).not.toHaveBeenCalled(); - expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + expect(resolveBundledSurfaceMock).not.toHaveBeenCalled(); }); - it("skips bundled setup promotion without a manifest feature", () => { + it("restores the exact former common tier when no declarations resolve", () => { + expect( + resolveSingleAccountKeysToMove({ + channelKey: "demo", + channel: { + ...valuesFor(legacyCommonKeys), + ...valuesFor(legacySetupOnlyKeys), + }, + }), + ).toEqual(legacyCommonKeys); + }); + + it("adds the exact former setup-only tier on direct setup paths", () => { + expect( + resolveSingleAccountKeysToMove({ + channelKey: "demo", + channel: { + ...valuesFor(legacyCommonKeys), + ...valuesFor(legacySetupOnlyKeys), + }, + includeSetupKeys: true, + }), + ).toEqual([...legacyCommonKeys, ...legacySetupOnlyKeys]); + }); + + it("keeps WeCom botId and secret in the generic tier", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "demo", + channel: { + tokenFile: "/tmp/token", + botId: "legacy-wecom-bot", + secret: "legacy-wecom-secret", + }, + }); + + expect(keys).toEqual(["tokenFile", "botId", "secret"]); + }); + + it("applies the legacy tier to a resolved but undeclared adapter", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "community", + channel: { + dmPolicy: "allowlist", + appToken: "legacy-app-token", + accessToken: "legacy-access-token", + rooms: { lobby: {} }, + }, + setupSurface: {}, + includeSetupKeys: true, + }); + + expect(keys).toEqual(["dmPolicy", "appToken", "accessToken", "rooms"]); + }); + + it("treats a declared empty list as authoritative", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "community", + channel: { + dmPolicy: "allowlist", + streaming: { mode: "partial" }, + appToken: "legacy-app-token", + rooms: { lobby: {} }, + }, + setupSurface: { singleAccountKeysToMove: [] }, + includeSetupKeys: true, + }); + + expect(keys).toEqual(["dmPolicy", "streaming"]); + }); + + it("prefers a caller-supplied setup surface over registry and bundled lookup", () => { + getLoadedChannelPluginMock.mockReturnValue({ + setup: { singleAccountKeysToMove: ["loadedKey"] }, + }); + resolveBundledSurfaceMock.mockReturnValue({ singleAccountKeysToMove: ["bundledKey"] }); + + const keys = resolveSingleAccountKeysToMove({ + channelKey: "scoped", + channel: { + callerKey: true, + loadedKey: true, + bundledKey: true, + }, + setupSurface: { singleAccountKeysToMove: ["callerKey"] }, + resolveBundledSurface: resolveBundledSurfaceMock, + }); + + expect(keys).toEqual(["callerKey"]); + expect(getLoadedChannelPluginMock).not.toHaveBeenCalled(); + expect(resolveBundledSurfaceMock).not.toHaveBeenCalled(); + }); + + it("unions the setup generic tier with plugin-declared keys", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "demo", + channel: { + streaming: { mode: "partial" }, + appToken: "xapp-test", + unrelated: true, + }, + setupSurface: { singleAccountKeysToMove: ["appToken"] }, + includeSetupKeys: true, + }); + + expect(keys).toEqual(["streaming", "appToken"]); + }); + + it("does not apply legacy keys to a declared in-repo surface", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "matrix", + channel: { + homeserver: "https://matrix.example.org", + streaming: { mode: "partial" }, + appToken: "not-matrix", + rooms: { lobby: {} }, + }, + setupSurface: { singleAccountKeysToMove: ["homeserver"] }, + includeSetupKeys: true, + }); + + expect(keys).toEqual(["homeserver", "streaming"]); + }); + + it("defers only undeclared keys outside generic and legacy coverage", () => { + expect( + resolveSingleAccountPromotion({ + channelKey: "community", + channel: { + accounts: { work: {} }, + dmPolicy: "allowlist", + appToken: "legacy-app-token", + }, + setupSurface: {}, + }), + ).toMatchObject({ + keysToMove: ["dmPolicy", "appToken"], + shouldDeferPromotion: false, + }); + + expect( + resolveSingleAccountPromotion({ + channelKey: "community", + channel: { + accounts: { work: {} }, + dmPolicy: "allowlist", + appToken: "legacy-app-token", + customAuth: "uncovered", + }, + setupSurface: {}, + }), + ).toMatchObject({ shouldDeferPromotion: true }); + + expect( + resolveSingleAccountPromotion({ + channelKey: "community", + channel: { + accounts: { work: {} }, + dmPolicy: "allowlist", + customAuth: "declared-none", + }, + setupSurface: { singleAccountKeysToMove: [] }, + }), + ).toMatchObject({ + keysToMove: ["dmPolicy"], + shouldDeferPromotion: false, + }); + }); + + it("does not consult bundled artifacts without an injected resolver", () => { const keys = resolveSingleAccountKeysToMove({ channelKey: "demo", channel: { @@ -57,20 +285,11 @@ describe("setup promotion helpers", () => { expect(keys).toEqual(["dmPolicy", "allowFrom", "groupPolicy", "groupAllowFrom"]); expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("demo"); - expect(hasBundledChannelPackageSetupFeatureMock).toHaveBeenCalledWith( - "demo", - "configPromotion", - ); - expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + expect(resolveBundledSurfaceMock).not.toHaveBeenCalled(); }); - it("loads bundled setup only for non-static migration keys", () => { - hasBundledChannelPackageSetupFeatureMock.mockReturnValue(true); - getBundledChannelPluginMock.mockReturnValue({ - setup: { - singleAccountKeysToMove: ["customAuth"], - }, - }); + it("uses an injected bundled surface for non-static migration keys", () => { + resolveBundledSurfaceMock.mockReturnValue({ singleAccountKeysToMove: ["customAuth"] }); expect( resolveSingleAccountKeysToMove({ @@ -78,9 +297,10 @@ describe("setup promotion helpers", () => { channel: { customAuth: "secret", }, + resolveBundledSurface: resolveBundledSurfaceMock, }), ).toEqual(["customAuth"]); - expect(getBundledChannelPluginMock).toHaveBeenCalledWith("demo"); + expect(resolveBundledSurfaceMock).toHaveBeenCalledWith("demo"); }); it("honors loaded plugin named-account filters without bundled fallback", () => { @@ -102,16 +322,11 @@ describe("setup promotion helpers", () => { }); expect(keys).toEqual(["token"]); - expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + expect(resolveBundledSurfaceMock).not.toHaveBeenCalled(); }); it("loads bundled setup for named-account filters before registry bootstrap", () => { - hasBundledChannelPackageSetupFeatureMock.mockReturnValue(true); - getBundledChannelPluginMock.mockReturnValue({ - setup: { - namedAccountPromotionKeys: ["token"], - }, - }); + resolveBundledSurfaceMock.mockReturnValue({ namedAccountPromotionKeys: ["token"] }); const keys = resolveSingleAccountKeysToMove({ channelKey: "demo", @@ -122,10 +337,11 @@ describe("setup promotion helpers", () => { token: "secret", dmPolicy: "allowlist", }, + resolveBundledSurface: resolveBundledSurfaceMock, }); expect(keys).toEqual(["token"]); expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("demo"); - expect(getBundledChannelPluginMock).toHaveBeenCalledWith("demo"); + expect(resolveBundledSurfaceMock).toHaveBeenCalledWith("demo"); }); }); diff --git a/src/channels/plugins/setup-promotion-helpers.ts b/src/channels/plugins/setup-promotion-helpers.ts index 9aa4f105b60a..b97117cf556b 100644 --- a/src/channels/plugins/setup-promotion-helpers.ts +++ b/src/channels/plugins/setup-promotion-helpers.ts @@ -3,11 +3,11 @@ * * Moves legacy single-account channel config into account-scoped config records. */ -import { getBundledChannelPlugin, hasBundledChannelPackageSetupFeature } from "./bundled.js"; -import { getLoadedChannelPlugin } from "./registry.js"; +import { getLoadedChannelPluginForRead } from "./registry-loaded.js"; import { collectSingleAccountPromotionEntries, isCommonSingleAccountPromotionKey, + isSetupSingleAccountPromotionKey, } from "./setup-promotion-keys.js"; type ChannelSectionBase = { @@ -15,7 +15,7 @@ type ChannelSectionBase = { accounts?: Record>; }; -type ChannelSetupPromotionSurface = { +export type ChannelSetupPromotionSurface = { singleAccountKeysToMove?: readonly string[]; namedAccountPromotionKeys?: readonly string[]; resolveSingleAccountPromotionTarget?: (params: { @@ -23,6 +23,72 @@ type ChannelSetupPromotionSurface = { }) => string | undefined; }; +type SingleAccountPromotionParams = { + channelKey: string; + channel: Record; + setupSurface?: ChannelSetupPromotionSurface; + includeSetupKeys?: boolean; + resolveBundledSurface?: (channelKey: string) => ChannelSetupPromotionSurface | null; +}; + +// Shipped Plugin SDK compatibility: out-of-tree setup adapters published before +// promotion declarations existed still inherit these former core tiers. Remove at +// the next SDK major after #112238 / PR 3 makes declarations mandatory. +const LEGACY_UNDECLARED_ADAPTER_PROMOTION_KEYS = { + common: [ + "appToken", + "account", + "signalNumber", + "authDir", + "cliPath", + "dbPath", + "httpUrl", + "httpHost", + "httpPort", + "webhookSecret", + "service", + "region", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "url", + "code", + ], + setupOnly: [ + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "threadReplies", + "startupVerification", + "startupVerificationCooldownHours", + "autoJoin", + "autoJoinAllowlist", + "rooms", + ], +} as const; + +const legacyUndeclaredAdapterCommonPromotionKeys = new Set( + LEGACY_UNDECLARED_ADAPTER_PROMOTION_KEYS.common, +); +const legacyUndeclaredAdapterSetupOnlyPromotionKeys = new Set( + LEGACY_UNDECLARED_ADAPTER_PROMOTION_KEYS.setupOnly, +); + +function hasPromotionDeclarations(surface: ChannelSetupPromotionSurface | null): boolean { + return Boolean(surface && Object.hasOwn(surface, "singleAccountKeysToMove")); +} + +function isLegacyUndeclaredAdapterPromotionKey(key: string, includeSetupKeys: boolean): boolean { + return ( + legacyUndeclaredAdapterCommonPromotionKeys.has(key) || + (includeSetupKeys && legacyUndeclaredAdapterSetupOnlyPromotionKeys.has(key)) + ); +} + function asPromotionSurface(setup: unknown): ChannelSetupPromotionSurface | null { return setup && typeof setup === "object" ? (setup as ChannelSetupPromotionSurface) : null; } @@ -30,61 +96,69 @@ function asPromotionSurface(setup: unknown): ChannelSetupPromotionSurface | null function getLoadedChannelSetupPromotionSurface( channelKey: string, ): ChannelSetupPromotionSurface | null { - return asPromotionSurface(getLoadedChannelPlugin(channelKey)?.setup); -} - -function getBundledChannelSetupPromotionSurface( - channelKey: string, -): ChannelSetupPromotionSurface | null { - if (!hasBundledChannelPackageSetupFeature(channelKey, "configPromotion")) { - return null; - } - return asPromotionSurface(getBundledChannelPlugin(channelKey)?.setup); + return asPromotionSurface(getLoadedChannelPluginForRead(channelKey)?.setup); } /** * Resolves all root-level keys eligible for single-account promotion. */ -export function resolveSingleAccountKeysToMove(params: { - channelKey: string; - channel: Record; -}): string[] { +export function resolveSingleAccountPromotion(params: SingleAccountPromotionParams) { const { entries, hasNamedAccounts } = collectSingleAccountPromotionEntries(params.channel); if (entries.length === 0) { - return []; + return { keysToMove: [], shouldDeferPromotion: false }; } - let loadedSetupSurface: ChannelSetupPromotionSurface | null | undefined; - const resolveLoadedSetupSurface = () => { - loadedSetupSurface ??= getLoadedChannelSetupPromotionSurface(params.channelKey); - return loadedSetupSurface; - }; - let bundledSetupSurface: ChannelSetupPromotionSurface | null | undefined; - const resolveBundledSetupSurface = () => { - bundledSetupSurface ??= getBundledChannelSetupPromotionSurface(params.channelKey); - return bundledSetupSurface; + const callerSetupSurface = + params.setupSurface === undefined ? undefined : asPromotionSurface(params.setupSurface); + let discoveredSetupSurface: ChannelSetupPromotionSurface | null | undefined; + const resolveSetupSurface = () => { + if (callerSetupSurface !== undefined) { + return callerSetupSurface; + } + if (discoveredSetupSurface === undefined) { + discoveredSetupSurface = + getLoadedChannelSetupPromotionSurface(params.channelKey) ?? + params.resolveBundledSurface?.(params.channelKey) ?? + null; + } + return discoveredSetupSurface; }; + const isGenericPromotionKey = params.includeSetupKeys + ? isSetupSingleAccountPromotionKey + : isCommonSingleAccountPromotionKey; + const isLegacyPromotionKey = (key: string) => + isLegacyUndeclaredAdapterPromotionKey(key, params.includeSetupKeys === true); + const hasUncoveredRootKeys = entries.some( + (key) => !isGenericPromotionKey(key) && !isLegacyPromotionKey(key), + ); + const buildResult = (keysToMove: string[]) => ({ + keysToMove, + shouldDeferPromotion: hasUncoveredRootKeys && !hasPromotionDeclarations(resolveSetupSurface()), + }); const keysToMove = entries.filter((key) => { - if (isCommonSingleAccountPromotionKey(key)) { + if (isGenericPromotionKey(key)) { return true; } - return Boolean( - resolveLoadedSetupSurface()?.singleAccountKeysToMove?.includes(key) || - resolveBundledSetupSurface()?.singleAccountKeysToMove?.includes(key), - ); + const setupSurface = resolveSetupSurface(); + return hasPromotionDeclarations(setupSurface) + ? Boolean(setupSurface?.singleAccountKeysToMove?.includes(key)) + : isLegacyPromotionKey(key); }); if (!hasNamedAccounts || keysToMove.length === 0) { - return keysToMove; + return buildResult(keysToMove); } // Once named accounts exist, only keys explicitly allowed for named-account // promotion should move. This avoids flattening root-only channel settings. - const namedAccountPromotionKeys = - resolveLoadedSetupSurface()?.namedAccountPromotionKeys ?? - resolveBundledSetupSurface()?.namedAccountPromotionKeys; + const namedAccountPromotionKeys = resolveSetupSurface()?.namedAccountPromotionKeys; if (!namedAccountPromotionKeys) { - return keysToMove; + return buildResult(keysToMove); } - return keysToMove.filter((key) => namedAccountPromotionKeys.includes(key)); + return buildResult(keysToMove.filter((key) => namedAccountPromotionKeys.includes(key))); +} + +/** Resolves all root-level keys eligible for single-account promotion. */ +export function resolveSingleAccountKeysToMove(params: SingleAccountPromotionParams): string[] { + return resolveSingleAccountPromotion(params).keysToMove; } diff --git a/src/channels/plugins/setup-promotion-keys.ts b/src/channels/plugins/setup-promotion-keys.ts index 3eaf8aa7bfa4..45621220e3b5 100644 --- a/src/channels/plugins/setup-promotion-keys.ts +++ b/src/channels/plugins/setup-promotion-keys.ts @@ -5,30 +5,13 @@ const COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS = [ "name", "token", "tokenFile", + // Tencent's out-of-tree @wecom/wecom-openclaw-plugin still writes root + // botId/secret. Keep promoting them until WeCom publishes plugin declarations. "botId", "secret", "botToken", - "appToken", - "account", - "signalNumber", - "authDir", - "cliPath", - "dbPath", - "httpUrl", - "httpHost", - "httpPort", "webhookPath", "webhookUrl", - "webhookSecret", - "service", - "region", - "homeserver", - "userId", - "accessToken", - "password", - "deviceName", - "url", - "code", "dmPolicy", "allowFrom", "groupPolicy", @@ -42,15 +25,9 @@ const COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS = [ const SETUP_SINGLE_ACCOUNT_PROMOTION_KEYS = [ ...COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS, "streaming", - "deviceId", - "avatarUrl", - "initialSyncLimit", - "encryption", - "allowlistOnly", "allowBots", "blockStreaming", "replyToMode", - "threadReplies", "textChunkLimit", "chunkMode", "responsePrefix", @@ -58,14 +35,9 @@ const SETUP_SINGLE_ACCOUNT_PROMOTION_KEYS = [ "ackReactionScope", "reactionNotifications", "threadBindings", - "startupVerification", - "startupVerificationCooldownHours", "mediaMaxMb", - "autoJoin", - "autoJoinAllowlist", "dm", "groups", - "rooms", "actions", ] as const; diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 7779a7b02a68..e5323b07da4a 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -63,6 +63,7 @@ import { setSetupChannelEnabled, splitSetupEntries, } from "./setup-wizard-helpers.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; const matrixSingleAccountKeysToMove = [ "allowBots", @@ -81,7 +82,11 @@ const matrixNamedAccountPromotionKeys = [ "homeserver", "userId", ] as const; -const telegramSingleAccountKeysToMove = ["streaming"] as const; +const telegramSingleAccountKeysToMove = ["streaming", "webhookSecret"] as const; +const telegramSetupSurface = { + applyAccountConfig: ({ cfg }) => cfg, + singleAccountKeysToMove: telegramSingleAccountKeysToMove, +} as ChannelSetupAdapter; function collectNamedAccountIds(accounts: Record): string[] { const ids: string[] = []; @@ -937,6 +942,7 @@ describe("patchChannelConfigForAccount", () => { allowFrom: ["100"], groupPolicy: "allowlist", streaming: { mode: "partial" }, + webhookSecret: "legacy-webhook-secret", }, }, }; @@ -946,6 +952,7 @@ describe("patchChannelConfigForAccount", () => { channel: "telegram", accountId: "work", patch: { botToken: "work-token" }, + setupSurface: telegramSetupSurface, }); expect(next.channels?.telegram?.accounts?.default).toEqual({ @@ -953,11 +960,13 @@ describe("patchChannelConfigForAccount", () => { allowFrom: ["100"], groupPolicy: "allowlist", streaming: { mode: "partial" }, + webhookSecret: "legacy-webhook-secret", }); expect(next.channels?.telegram?.botToken).toBeUndefined(); expect(next.channels?.telegram?.allowFrom).toBeUndefined(); expect(next.channels?.telegram?.groupPolicy).toBeUndefined(); expect(next.channels?.telegram?.streaming).toBeUndefined(); + expect(next.channels?.telegram?.webhookSecret).toBeUndefined(); expect(next.channels?.telegram?.accounts?.work?.botToken).toBe("work-token"); }); diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 39f3f8e6db0f..4cd69a127574 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -28,6 +28,7 @@ import type { PromptAccountId, PromptAccountIdParams, } from "./setup-wizard-types.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; const loadProviderAuthInput = createLazyRuntimeModule( () => import("../../plugins/provider-auth-ref.js"), @@ -255,6 +256,7 @@ export function setAccountAllowFromForChannel(params: { channel: string; accountId: string; allowFrom: string[]; + setupSurface?: ChannelSetupAdapter; }): OpenClawConfig { const { cfg, channel, accountId, allowFrom } = params; return patchConfigForScopedAccount({ @@ -262,6 +264,7 @@ export function setAccountAllowFromForChannel(params: { channel, accountId, patch: { allowFrom }, + setupSurface: params.setupSurface, ensureEnabled: false, }); } @@ -885,8 +888,9 @@ function patchConfigForScopedAccount(params: { accountId: string; patch: Record; ensureEnabled: boolean; + setupSurface?: ChannelSetupAdapter; }): OpenClawConfig { - const { cfg, channel, accountId, patch, ensureEnabled } = params; + const { cfg, channel, accountId, patch, ensureEnabled, setupSurface } = params; const channelConfig = cfg.channels?.[channel] as | { accounts?: Record } | undefined; @@ -899,6 +903,7 @@ function patchConfigForScopedAccount(params: { : moveSingleAccountChannelSectionToDefaultAccount({ cfg, channelKey: channel, + setupSurface, }); return patchScopedAccountConfig({ cfg: seededCfg, @@ -915,6 +920,7 @@ export function patchChannelConfigForAccount(params: { channel: AccountScopedChannel; accountId: string; patch: Record; + setupSurface?: ChannelSetupAdapter; }): OpenClawConfig { return patchConfigForScopedAccount({ ...params, diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 7738d7b719b5..979544e57860 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -23,6 +23,7 @@ import type { ChannelSetupStatus, ChannelSetupStatusContext, } from "./setup-wizard-types.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelSetupInput } from "./types.core.js"; export type { @@ -48,6 +49,7 @@ function createWizardAccountScope(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; + setupSurface?: ChannelSetupAdapter; }): { cfg: OpenClawConfig; restore: (cfg: OpenClawConfig) => OpenClawConfig } { const accountId = normalizeAccountId(params.accountId); const initialChannel = getChannelSection(params.cfg, params.channelKey); @@ -60,6 +62,7 @@ function createWizardAccountScope(params: { const cfg = moveSingleAccountChannelSectionToDefaultAccount({ cfg: params.cfg, channelKey: params.channelKey, + setupSurface: params.setupSurface, }); const channel = getChannelSection(cfg, params.channelKey); const previousDefaultAccount = channel.defaultAccount; @@ -280,6 +283,7 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { cfg, channelKey: plugin.id, accountId, + setupSurface: plugin.setup, }) : { cfg, restore: (currentCfg: OpenClawConfig) => currentCfg }; let next = accountScope.cfg; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 809390ffe7c2..d04b22d61a6a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -283,6 +283,7 @@ async function channelsAddCommandImpl( nextConfig = moveSingleAccountChannelSectionToDefaultAccount({ cfg: nextConfig, channelKey: channel, + setupSurface: plugin.setup, }); } diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 42aa331f358e..3c466d7eff68 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -563,7 +563,18 @@ vi.mock("../channels/plugins/setup-promotion-helpers.js", () => { "name", "token", "tokenFile", + "botId", + "secret", "botToken", + "webhookPath", + "webhookUrl", + "dmPolicy", + "allowFrom", + "groupPolicy", + "groupAllowFrom", + "defaultTo", + ]); + const legacyCommonSingleAccountKeys = new Set([ "appToken", "account", "signalNumber", @@ -573,8 +584,6 @@ vi.mock("../channels/plugins/setup-promotion-helpers.js", () => { "httpUrl", "httpHost", "httpPort", - "webhookPath", - "webhookUrl", "webhookSecret", "service", "region", @@ -585,51 +594,100 @@ vi.mock("../channels/plugins/setup-promotion-helpers.js", () => { "deviceName", "url", "code", - "dmPolicy", - "allowFrom", - "groupPolicy", - "groupAllowFrom", - "defaultTo", ]); - const fallbackSingleAccountKeys: Record = { - telegram: ["streaming"], + const declaredSingleAccountKeys: Record = { + discord: [], + imessage: ["cliPath", "dbPath", "service", "region"], + irc: ["password"], + matrix: [ + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", + ], + mattermost: [], + "nextcloud-talk": ["rooms"], + signal: ["signalNumber", "account", "cliPath", "httpUrl", "httpHost", "httpPort"], + slack: ["appToken"], + telegram: ["streaming", "webhookSecret"], + tlon: ["url", "code"], + twitch: ["accessToken"], + whatsapp: ["authDir"], + zalo: ["webhookSecret", "tokenFile"], }; const namedAccountPromotionKeys: Record = { + matrix: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", + ], telegram: ["botToken", "tokenFile"], }; + const resolveKeys = ({ + channelKey, + channel, + }: { + channelKey: string; + channel: Record; + }) => { + const accounts = + channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts) + ? (channel.accounts as Record) + : {}; + const hasNamedAccounts = Object.keys(accounts).some(Boolean); + const allowedNamedKeys = namedAccountPromotionKeys[channelKey]; + const hasDeclarations = Object.hasOwn(declaredSingleAccountKeys, channelKey); + const declaredKeys = declaredSingleAccountKeys[channelKey]; + return Object.entries(channel) + .filter(([key, value]) => { + if (key === "accounts" || key === "enabled" || value === undefined) { + return false; + } + const isKnownKey = + commonSingleAccountKeys.has(key) || + (hasDeclarations + ? (declaredKeys?.includes(key) ?? false) + : legacyCommonSingleAccountKeys.has(key)); + if (!isKnownKey) { + return false; + } + if (hasNamedAccounts && allowedNamedKeys && !allowedNamedKeys.includes(key)) { + return false; + } + return true; + }) + .map(([key]) => key); + }; + return { - resolveSingleAccountKeysToMove: ({ - channelKey, - channel, - }: { + resolveSingleAccountKeysToMove: resolveKeys, + resolveSingleAccountPromotion: (params: { channelKey: string; channel: Record; - }) => { - const accounts = - channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts) - ? (channel.accounts as Record) - : {}; - const hasNamedAccounts = Object.keys(accounts).some(Boolean); - const allowedNamedKeys = namedAccountPromotionKeys[channelKey]; - return Object.entries(channel) - .filter(([key, value]) => { - if (key === "accounts" || key === "enabled" || value === undefined) { - return false; - } - const isKnownKey = - commonSingleAccountKeys.has(key) || - (fallbackSingleAccountKeys[channelKey]?.includes(key) ?? false); - if (!isKnownKey) { - return false; - } - if (hasNamedAccounts && allowedNamedKeys && !allowedNamedKeys.includes(key)) { - return false; - } - return true; - }) - .map(([key]) => key); - }, + }) => ({ + keysToMove: resolveKeys(params), + shouldDeferPromotion: + !Object.hasOwn(declaredSingleAccountKeys, params.channelKey) && + Object.keys(params.channel).some( + (key) => + !commonSingleAccountKeys.has(key) && + !legacyCommonSingleAccountKeys.has(key) && + !["accounts", "defaultAccount", "enabled"].includes(key), + ), + }), }; }); @@ -2657,6 +2715,77 @@ describe("doctor config flow", () => { ).toEqual(["123"]); }); + it("defers absent-plugin promotion instead of creating a partial default account", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + "uninstalled-demo": { + dmPolicy: "allowlist", + appToken: "covered-legacy-key", + customAuth: "plugin-owned", + accounts: { + work: { enabled: true }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const channel = ( + result.cfg as unknown as { + channels: Record< + string, + { + dmPolicy?: string; + appToken?: string; + customAuth?: string; + accounts?: Record; + } + >; + } + ).channels["uninstalled-demo"]; + expect(channel?.dmPolicy).toBe("allowlist"); + expect(channel?.appToken).toBe("covered-legacy-key"); + expect(channel?.customAuth).toBe("plugin-owned"); + expect(channel?.accounts).toEqual({ work: { enabled: true } }); + }); + + it("promotes covered legacy keys when an absent plugin has no declarations", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + "legacy-demo": { + dmPolicy: "allowlist", + appToken: "legacy-app-token", + accounts: { + work: { enabled: true }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const channel = ( + result.cfg as unknown as { + channels: Record< + string, + { dmPolicy?: string; appToken?: string; accounts?: Record } + >; + } + ).channels["legacy-demo"]; + expect(channel?.dmPolicy).toBeUndefined(); + expect(channel?.appToken).toBeUndefined(); + expect(channel?.accounts?.default).toEqual({ + dmPolicy: "allowlist", + appToken: "legacy-app-token", + }); + expect(channel?.accounts?.work).toEqual({ enabled: true, dmPolicy: "allowlist" }); + }); + it('repairs open dmPolicy allowFrom variants with ["*"] in one pass', async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 33a2e642dc41..318d78dabce2 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -5,6 +5,8 @@ import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { validateConfigObject } from "../config/validation.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { maybeRepairCodexRoutes } from "./doctor/shared/codex-route-warnings.js"; import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./doctor/shared/legacy-config-migrations.js"; @@ -163,6 +165,7 @@ describe("normalizeCompatibilityConfigValues", () => { }); beforeEach(() => { + resetPluginRuntimeStateForTest(); fs.rmSync(tempOauthDir, { recursive: true, force: true }); fs.mkdirSync(tempOauthDir, { recursive: true }); }); @@ -439,6 +442,101 @@ describe("normalizeCompatibilityConfigValues", () => { ); }); + it("defers the whole promotion for uncovered keys on an undeclared channel", () => { + const config = { + channels: { + "uninstalled-demo": { + dmPolicy: "allowlist", + appToken: "covered-legacy-key", + customAuth: "keep-at-root", + accounts: { work: { enabled: true } }, + }, + }, + } as unknown as OpenClawConfig; + + const res = normalizeCompatibilityConfigValues(config); + + expect(res.config).toEqual(config); + expect(res.changes).toStrictEqual([]); + }); + + it("promotes the legacy tier when a loaded adapter is undeclared", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "undeclared-demo", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "undeclared-demo", label: "Undeclared Demo" }), + setup: { + applyAccountConfig: ({ cfg }: { cfg: OpenClawConfig }) => cfg, + }, + }, + }, + ]), + ); + + const res = normalizeCompatibilityConfigValues({ + channels: { + "undeclared-demo": { + dmPolicy: "allowlist", + appToken: "legacy-app-token", + accounts: { work: { enabled: true } }, + }, + }, + } as unknown as OpenClawConfig); + + const channel = res.config.channels?.["undeclared-demo"] as + | { dmPolicy?: string; appToken?: string; accounts?: Record } + | undefined; + expect(channel?.dmPolicy).toBeUndefined(); + expect(channel?.appToken).toBeUndefined(); + expect(channel?.accounts?.default).toEqual({ + dmPolicy: "allowlist", + appToken: "legacy-app-token", + }); + expect(channel?.accounts?.work).toEqual({ enabled: true, dmPolicy: "allowlist" }); + }); + + it("promotes generic and declared keys together after the plugin becomes available", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "late-demo", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "late-demo", label: "Late Demo" }), + setup: { + applyAccountConfig: ({ cfg }: { cfg: OpenClawConfig }) => cfg, + singleAccountKeysToMove: ["customAuth"], + }, + }, + }, + ]), + ); + + const res = normalizeCompatibilityConfigValues({ + channels: { + "late-demo": { + dmPolicy: "allowlist", + customAuth: "move-with-plugin", + accounts: { work: { enabled: true } }, + }, + }, + } as unknown as OpenClawConfig); + + const channel = res.config.channels?.["late-demo"] as + | { dmPolicy?: string; customAuth?: string; accounts?: Record } + | undefined; + expect(channel?.dmPolicy).toBeUndefined(); + expect(channel?.customAuth).toBeUndefined(); + expect(channel?.accounts?.default).toEqual({ + dmPolicy: "allowlist", + customAuth: "move-with-plugin", + }); + expect(channel?.accounts?.work).toEqual({ enabled: true, dmPolicy: "allowlist" }); + }); + it.each(["discord", "slack", "telegram", "signal", "imessage", "irc"])( "preserves inherited %s access policy when seeding accounts.default", (channelId) => { diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 98edf86b6c0c..b8971e4f7a81 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -5,10 +5,12 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; -import { resolveSingleAccountKeysToMove } from "../../../channels/plugins/setup-promotion-helpers.js"; +import { resolveBundledChannelSetupPromotionSurface } from "../../../channels/plugins/setup-promotion-bundled.js"; +import { resolveSingleAccountPromotion } from "../../../channels/plugins/setup-promotion-helpers.js"; import { resolveNormalizedProviderModelMaxTokens } from "../../../config/defaults.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { DEFAULT_GOOGLE_API_BASE_URL } from "../../../infra/google-api-base-url.js"; +import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { isBlockedLegacyCodexModelRef, @@ -24,6 +26,7 @@ import { export { normalizeLegacyTalkConfig } from "./legacy-talk-config-normalizer.js"; const INHERITED_ACCOUNT_POLICY_KEYS = ["dmPolicy", "allowFrom", "groupPolicy", "groupAllowFrom"]; +const log = createSubsystemLogger("doctor"); /** Migrate legacy browser/Chrome relay config to current browser profile settings. */ export function normalizeLegacyBrowserConfig( @@ -144,10 +147,20 @@ export function seedMissingDefaultAccountsFromSingleAccountBase( if (hasDefault) { continue; } - const keysToMove = resolveSingleAccountKeysToMove({ + const promotion = resolveSingleAccountPromotion({ channelKey: channelId, channel: rawChannel, + resolveBundledSurface: resolveBundledChannelSetupPromotionSurface, }); + // Defer only undeclared keys outside generic + legacy coverage. A partial + // accounts.default would make later runs skip and permanently strand them at root. + if (promotion.shouldDeferPromotion) { + log.debug( + `Deferring channels.${channelId} single-account promotion until its plugin declares uncovered root keys.`, + ); + continue; + } + const keysToMove = promotion.keysToMove; if (keysToMove.length === 0) { continue; }