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
This commit is contained in:
Peter Steinberger
2026-07-21 06:48:30 -07:00
committed by GitHub
parent c1cf439942
commit e1d5d3dc16
43 changed files with 867 additions and 242 deletions

View File

@@ -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

View File

@@ -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.<id>.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.
<Note>
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.
</Note>

View File

@@ -157,6 +157,7 @@ export function applyClickClackSetupConfigPatch(params: {
: moveSingleAccountChannelSectionToDefaultAccount({
cfg: params.cfg,
channelKey: channel,
setupSurface: clickClackSetupAdapter,
});
const namedConfig = applyAccountNameToChannelSection({
cfg: scopedConfig,

View File

@@ -16,6 +16,7 @@
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true,
"legacyStateMigrations": true
},
"channel": {

View File

@@ -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"),

View File

@@ -18,6 +18,9 @@
"allowInvalidConfigRecovery": true
},
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "irc",
"configuredState": {

View File

@@ -111,6 +111,7 @@ export function setIrcGroupAccess(
}
export const ircSetupAdapter: ChannelSetupAdapter = {
singleAccountKeysToMove: ["password"],
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({

View File

@@ -100,26 +100,13 @@ describe("matrix setup post-write bootstrap", () => {
values: Record<string, string | undefined>,
run: () => Promise<T> | T,
) {
const previousEnv = Object.fromEntries(
Object.keys(values).map((key) => [key, process.env[key]]),
) as Record<string, string | undefined>;
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" });

View File

@@ -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",

View File

@@ -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,

View File

@@ -24,6 +24,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "nextcloud-talk",
"configuredState": {

View File

@@ -207,6 +207,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
};
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
singleAccountKeysToMove: ["rooms"],
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
prepareAccountConfigInput: ({ input }) => ({
...input,

View File

@@ -14,6 +14,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "signal",
"approvalFlags": ["native"],

View File

@@ -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<ChannelSetupWizard>) {
return createDelegatedSetupWizardProxy({

View File

@@ -33,6 +33,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "slack",
"configuredState": {

View File

@@ -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({

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 }) => {

View File

@@ -31,6 +31,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "tlon",
"label": "Tlon",

View File

@@ -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);

View File

@@ -21,6 +21,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"install": {
"npmSpec": "@openclaw/twitch",
"defaultChoice": "npm",

View File

@@ -383,6 +383,7 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
};
export const twitchSetupAdapter: ChannelSetupAdapter = {
singleAccountKeysToMove: ["accessToken"],
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
applyAccountConfig: ({ cfg, accountId }) =>
setTwitchAccount(

View File

@@ -30,6 +30,7 @@
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true,
"legacyStateMigrations": true,
"legacySessionSurfaces": true
},

View File

@@ -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<typeof createQueuedWizardPrompter>;
cfg?: OpenClawConfig;

View File

@@ -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({

View File

@@ -24,6 +24,9 @@
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"configPromotion": true
},
"channel": {
"id": "zalo",
"configuredState": {

View File

@@ -23,26 +23,29 @@ type ZaloAccountSetupConfig = {
allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
};
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",

View File

@@ -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, unknown>): 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");

View File

@@ -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<string, Record<string, unknown>>;
};
const NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL: Record<string, readonly string[]> = {
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<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
@@ -460,22 +441,16 @@ function resolveExistingAccountKey(
return targetAccountId;
}
function resolveSingleAccountKeysToMove(params: {
channelKey: string;
channel: Record<string, unknown>;
}): 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<string, unknown> | 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,

View File

@@ -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;
}

View File

@@ -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<string, string> {
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");
});
});

View File

@@ -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<string, Record<string, unknown>>;
};
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<string, unknown>;
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<string>(
LEGACY_UNDECLARED_ADAPTER_PROMOTION_KEYS.common,
);
const legacyUndeclaredAdapterSetupOnlyPromotionKeys = new Set<string>(
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, unknown>;
}): 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;
}

View File

@@ -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;

View File

@@ -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, unknown>): 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");
});

View File

@@ -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<string, unknown>;
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<string, unknown> }
| 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<string, unknown>;
setupSurface?: ChannelSetupAdapter;
}): OpenClawConfig {
return patchConfigForScopedAccount({
...params,

View File

@@ -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;

View File

@@ -283,6 +283,7 @@ async function channelsAddCommandImpl(
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
cfg: nextConfig,
channelKey: channel,
setupSurface: plugin.setup,
});
}

View File

@@ -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<string, readonly string[]> = {
telegram: ["streaming"],
const declaredSingleAccountKeys: Record<string, readonly string[]> = {
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<string, readonly string[]> = {
matrix: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"avatarUrl",
"initialSyncLimit",
"encryption",
],
telegram: ["botToken", "tokenFile"],
};
const resolveKeys = ({
channelKey,
channel,
}: {
channelKey: string;
channel: Record<string, unknown>;
}) => {
const accounts =
channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts)
? (channel.accounts as Record<string, unknown>)
: {};
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<string, unknown>;
}) => {
const accounts =
channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts)
? (channel.accounts as Record<string, unknown>)
: {};
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<string, unknown>;
}
>;
}
).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<string, unknown> }
>;
}
).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,

View File

@@ -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<string, unknown> }
| 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<string, unknown> }
| 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) => {

View File

@@ -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;
}