mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:31:35 +00:00
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:
committed by
GitHub
parent
c1cf439942
commit
e1d5d3dc16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -157,6 +157,7 @@ export function applyClickClackSetupConfigPatch(params: {
|
||||
: moveSingleAccountChannelSectionToDefaultAccount({
|
||||
cfg: params.cfg,
|
||||
channelKey: channel,
|
||||
setupSurface: clickClackSetupAdapter,
|
||||
});
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: scopedConfig,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true,
|
||||
"legacyStateMigrations": true
|
||||
},
|
||||
"channel": {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "irc",
|
||||
"configuredState": {
|
||||
|
||||
@@ -111,6 +111,7 @@ export function setIrcGroupAccess(
|
||||
}
|
||||
|
||||
export const ircSetupAdapter: ChannelSetupAdapter = {
|
||||
singleAccountKeysToMove: ["password"],
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"configuredState": {
|
||||
|
||||
@@ -207,6 +207,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
|
||||
};
|
||||
|
||||
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
|
||||
singleAccountKeysToMove: ["rooms"],
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
prepareAccountConfigInput: ({ input }) => ({
|
||||
...input,
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "signal",
|
||||
"approvalFlags": ["native"],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "slack",
|
||||
"configuredState": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "tlon",
|
||||
"label": "Tlon",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/twitch",
|
||||
"defaultChoice": "npm",
|
||||
|
||||
@@ -383,6 +383,7 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
||||
};
|
||||
|
||||
export const twitchSetupAdapter: ChannelSetupAdapter = {
|
||||
singleAccountKeysToMove: ["accessToken"],
|
||||
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
|
||||
applyAccountConfig: ({ cfg, accountId }) =>
|
||||
setTwitchAccount(
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true,
|
||||
"legacyStateMigrations": true,
|
||||
"legacySessionSurfaces": true
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"configPromotion": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "zalo",
|
||||
"configuredState": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
src/channels/plugins/setup-promotion-bundled.ts
Normal file
17
src/channels/plugins/setup-promotion-bundled.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -283,6 +283,7 @@ async function channelsAddCommandImpl(
|
||||
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
|
||||
cfg: nextConfig,
|
||||
channelKey: channel,
|
||||
setupSurface: plugin.setup,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user