From 5ae059db16c690841c63151424135cb6659dbbf0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 16:35:25 -0400 Subject: [PATCH] test: speed legacy state migration discovery Keep bundled legacy migration discovery on narrow setup-entry surfaces so state-migration tests and doctor cold paths avoid unrelated channel runtime loads. Add targeted setup feature metadata, narrow Telegram/WhatsApp legacy contracts, and a path-only pairing SDK helper. --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../telegram/legacy-state-migrations-api.ts | 1 + extensions/telegram/package.json | 3 + extensions/telegram/setup-entry.ts | 4 + extensions/telegram/src/account-selection.ts | 151 +++++++++++++++ extensions/telegram/src/accounts.ts | 48 +---- extensions/telegram/src/state-migrations.ts | 4 +- .../whatsapp/legacy-session-surface-api.ts | 6 + .../whatsapp/legacy-state-migrations-api.ts | 1 + extensions/whatsapp/package.json | 4 + extensions/whatsapp/setup-entry.ts | 8 + extensions/whatsapp/src/session-contract.ts | 2 +- package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + .../plugins/bundled.shape-guard.test.ts | 142 ++++++++++++++ src/channels/plugins/bundled.ts | 178 +++++++++++++++--- src/infra/state-migrations.ts | 19 +- src/plugin-sdk/channel-entry-contract.ts | 43 +++++ src/plugin-sdk/channel-pairing-paths.ts | 1 + src/plugins/manifest.ts | 6 + 20 files changed, 549 insertions(+), 81 deletions(-) create mode 100644 extensions/telegram/legacy-state-migrations-api.ts create mode 100644 extensions/telegram/src/account-selection.ts create mode 100644 extensions/whatsapp/legacy-session-surface-api.ts create mode 100644 extensions/whatsapp/legacy-state-migrations-api.ts create mode 100644 src/plugin-sdk/channel-pairing-paths.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ba9e97784f0..b4782eed7d6 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json -2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl +052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json +a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl diff --git a/extensions/telegram/legacy-state-migrations-api.ts b/extensions/telegram/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..138d753daff --- /dev/null +++ b/extensions/telegram/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index ed5dbd87185..93587009a84 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -17,6 +17,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true + }, "channel": { "id": "telegram", "label": "Telegram", diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index d87b102fbfe..a3b942698ce 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "telegramSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectTelegramLegacyStateMigrations", + }, secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets", diff --git a/extensions/telegram/src/account-selection.ts b/extensions/telegram/src/account-selection.ts new file mode 100644 index 00000000000..c942056aecd --- /dev/null +++ b/extensions/telegram/src/account-selection.ts @@ -0,0 +1,151 @@ +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/g, "") + .replace(/-+$/g, ""); + return normalized || DEFAULT_AGENT_ID; +} + +function normalizeChannelId(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function resolveDefaultAgentId(cfg: OpenClawConfig): string { + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id; + return normalizeAgentId(chosen); +} + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { + if (key) { + ids.add(normalizeAccountId(key)); + } + } + return [...ids]; +} + +function resolveBindingAccount(params: { + binding: unknown; + channelId: string; +}): { agentId: string; accountId: string } | null { + if (!params.binding || typeof params.binding !== "object") { + return null; + } + const binding = params.binding as { + agentId?: unknown; + match?: { channel?: unknown; accountId?: unknown }; + }; + if (normalizeChannelId(binding.match?.channel) !== params.channelId) { + return null; + } + const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : ""; + if (!accountId.trim() || accountId.trim() === "*") { + return null; + } + return { + agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined), + accountId: normalizeAccountId(accountId), + }; +} + +function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] { + const ids = new Set(); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved) { + ids.add(resolved.accountId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null { + const defaultAgentId = resolveDefaultAgentId(cfg); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved?.agentId === defaultAgentId) { + return resolved.accountId; + } + } + return null; +} + +function combineAccountIds(params: { + configuredAccountIds: readonly string[]; + additionalAccountIds: readonly string[]; +}): string[] { + const ids = new Set(); + for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) { + ids.add(normalizeAccountId(id)); + } + if (ids.size === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveListedDefaultAccountId(params: { + accountIds: readonly string[]; + configuredDefaultAccountId: string | null | undefined; +}): string { + const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId); + if (configured && params.accountIds.includes(configured)) { + return configured; + } + if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { + return combineAccountIds({ + configuredAccountIds: listConfiguredAccountIds(cfg), + additionalAccountIds: listBoundAccountIds(cfg, "telegram"), + }); +} + +export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): { + accountId: string; + accountIds: string[]; + shouldWarnMissingDefault: boolean; +} { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); + if (boundDefault) { + return { + accountId: boundDefault, + accountIds: listTelegramAccountIds(cfg), + shouldWarnMissingDefault: false, + }; + } + const accountIds = listTelegramAccountIds(cfg); + const resolved = resolveListedDefaultAccountId({ + accountIds, + configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount, + }); + return { + accountId: resolved, + accountIds, + shouldWarnMissingDefault: + resolved === accountIds[0] && + !accountIds.includes(DEFAULT_ACCOUNT_ID) && + accountIds.length > 1, + }; +} + +export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { + return resolveDefaultTelegramAccountSelection(cfg).accountId; +} diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index db631928f1a..4bcfb21896c 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,12 +1,9 @@ import util from "node:util"; import { createAccountActionGate, - DEFAULT_ACCOUNT_ID, - listCombinedAccountIds, normalizeAccountId, normalizeOptionalAccountId, resolveAccountEntry, - resolveListedDefaultAccountId, resolveAccountWithDefaultFallback, type OpenClawConfig, } from "openclaw/plugin-sdk/account-core"; @@ -14,13 +11,13 @@ import type { TelegramAccountConfig, TelegramActionConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { - listBoundAccountIds, - resolveDefaultAgentBoundAccountId, -} from "openclaw/plugin-sdk/routing"; import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + listTelegramAccountIds as listSelectedTelegramAccountIds, + resolveDefaultTelegramAccountSelection, +} from "./account-selection.js"; import type { TelegramTransport } from "./fetch.js"; import { resolveTelegramToken } from "./token.js"; @@ -67,22 +64,8 @@ export type TelegramMediaRuntimeOptions = { dangerouslyAllowPrivateNetwork?: boolean; }; -function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { - if (key) { - ids.add(normalizeAccountId(key)); - } - } - return [...ids]; -} - export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { - const ids = listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds(cfg), - additionalAccountIds: listBoundAccountIds(cfg, "telegram"), - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, - }); + const ids = listSelectedTelegramAccountIds(cfg); debugAccounts("listTelegramAccountIds", ids); return ids; } @@ -95,26 +78,15 @@ export function resetMissingDefaultWarnFlag(): void { } export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { - const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); - if (boundDefault) { - return boundDefault; - } - const ids = listTelegramAccountIds(cfg); - const resolved = resolveListedDefaultAccountId({ - accountIds: ids, - configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount), - }); - if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) { - return resolved; - } - if (ids.length > 1 && !emittedMissingDefaultWarn) { + const selection = resolveDefaultTelegramAccountSelection(cfg); + if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) { emittedMissingDefaultWarn = true; getLog().warn( - `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + + `channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, ); } - return resolved; + return selection.accountId; } export function resolveTelegramAccountConfig( diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index 19147405828..455d5a77126 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; -import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing"; +import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { resolveDefaultTelegramAccountId } from "./account-selection.js"; function fileExists(pathValue: string): boolean { try { diff --git a/extensions/whatsapp/legacy-session-surface-api.ts b/extensions/whatsapp/legacy-session-surface-api.ts new file mode 100644 index 00000000000..ed94357bd4d --- /dev/null +++ b/extensions/whatsapp/legacy-session-surface-api.ts @@ -0,0 +1,6 @@ +import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js"; + +export const whatsappLegacySessionSurface = { + isLegacyGroupSessionKey, + canonicalizeLegacySessionKey, +}; diff --git a/extensions/whatsapp/legacy-state-migrations-api.ts b/extensions/whatsapp/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..2b228f175ec --- /dev/null +++ b/extensions/whatsapp/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index a695f97cff8..2c339ec282b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -25,6 +25,10 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true, + "legacySessionSurfaces": true + }, "channel": { "id": "whatsapp", "label": "WhatsApp", diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index b6c896a9dec..f7f88662785 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "whatsappSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectWhatsAppLegacyStateMigrations", + }, + legacySessionSurface: { + specifier: "./legacy-session-surface-api.js", + exportName: "whatsappLegacySessionSurface", + }, }); diff --git a/extensions/whatsapp/src/session-contract.ts b/extensions/whatsapp/src/session-contract.ts index a71fd843852..5e7f456f33f 100644 --- a/extensions/whatsapp/src/session-contract.ts +++ b/extensions/whatsapp/src/session-contract.ts @@ -1,4 +1,4 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; function extractLegacyWhatsAppGroupId(key: string): string | null { const trimmed = key.trim(); diff --git a/package.json b/package.json index ac3e51ec72f..9ccd78766cd 100644 --- a/package.json +++ b/package.json @@ -640,6 +640,10 @@ "types": "./dist/plugin-sdk/channel-pairing.d.ts", "default": "./dist/plugin-sdk/channel-pairing.js" }, + "./plugin-sdk/channel-pairing-paths": { + "types": "./dist/plugin-sdk/channel-pairing-paths.d.ts", + "default": "./dist/plugin-sdk/channel-pairing-paths.js" + }, "./plugin-sdk/channel-policy": { "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e2f2378acc7..a950bf1ff91 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -146,6 +146,7 @@ "channel-mention-gating", "channel-lifecycle", "channel-pairing", + "channel-pairing-paths", "channel-policy", "channel-send-result", "channel-targets", diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index d1e855a7d14..eef427856d4 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -385,6 +385,121 @@ describe("bundled channel entry shape guards", () => { } }); + it("loads setup-entry feature plugins without loading the main channel entry", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginDir = path.join(root, "dist", "extensions", "alpha"); + const testGlobal = globalThis as typeof globalThis & { + __bundledSetupOnlyMainLoaded?: boolean; + __bundledSetupOnlySetupLoaded?: number; + __bundledSetupOnlyPluginLoaded?: boolean; + }; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "globalThis.__bundledSetupOnlyMainLoaded = true;", + "throw new Error('main entry loaded');", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "globalThis.__bundledSetupOnlySetupLoaded = (globalThis.__bundledSetupOnlySetupLoaded ?? 0) + 1;", + "export default {", + " kind: 'bundled-channel-setup-entry',", + " features: { legacyStateMigrations: true },", + " loadSetupPlugin() {", + " globalThis.__bundledSetupOnlyPluginLoaded = true;", + " throw new Error('setup plugin loaded');", + " },", + " loadLegacyStateMigrationDetector() {", + " return ({ oauthDir }) => [{", + " kind: 'copy',", + " label: 'Alpha state',", + " sourcePath: oauthDir + '/legacy.json',", + " targetPath: oauthDir + '/alpha/legacy.json',", + " }];", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: () => [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + setupSource: { + source: "./setup-entry.js", + built: "./setup-entry.js", + }, + }, + ], + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => + path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ), + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-setup-only-feature", + ); + + const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors(); + expect( + detectors.map((detector) => + detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never), + ), + ).toEqual([ + [ + { + kind: "copy", + label: "Alpha state", + sourcePath: "/oauth/legacy.json", + targetPath: "/oauth/alpha/legacy.json", + }, + ], + ]); + expect(testGlobal.__bundledSetupOnlySetupLoaded).toBe(1); + expect(testGlobal.__bundledSetupOnlyMainLoaded).toBeUndefined(); + expect(testGlobal.__bundledSetupOnlyPluginLoaded).toBeUndefined(); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(root, { recursive: true, force: true }); + delete testGlobal.__bundledSetupOnlyMainLoaded; + delete testGlobal.__bundledSetupOnlySetupLoaded; + delete testGlobal.__bundledSetupOnlyPluginLoaded; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders: string[] = []; @@ -414,6 +529,33 @@ describe("bundled channel entry shape guards", () => { expect(offenders).toEqual([]); }); + it("keeps setup-entry legacy feature hints mirrored in package metadata", () => { + const offenders: string[] = []; + + for (const extensionDir of bundledPluginRoots) { + const setupEntryPath = path.join(extensionDir, "setup-entry.ts"); + const packageJsonPath = path.join(extensionDir, "package.json"); + if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) { + continue; + } + const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { + setupFeatures?: Record; + }; + }; + for (const feature of ["legacyStateMigrations", "legacySessionSurfaces"]) { + const usesFeature = setupEntrySource.includes(`${feature}: true`); + const hasHint = packageJson.openclaw?.setupFeatures?.[feature] === true; + if (usesFeature !== hasHint) { + offenders.push(`${path.relative(process.cwd(), extensionDir)}:${feature}`); + } + } + } + + expect(offenders).toEqual([]); + }); + it("keeps bundled channel entrypoints free of static src imports", () => { const offenders: string[] = []; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 6f9cec3cf7c..e8966199049 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { + BundledChannelLegacySessionSurface, + BundledChannelLegacyStateMigrationDetector, +} from "../../plugin-sdk/channel-entry-contract.js"; import { listBundledChannelPluginMetadata, resolveBundledChannelGeneratedPath, @@ -32,6 +36,8 @@ type BundledChannelSetupEntryRuntimeContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => ChannelPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; features?: { legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; @@ -41,14 +47,15 @@ type BundledChannelSetupEntryRuntimeContract = { type GeneratedBundledChannelEntry = { id: string; entry: BundledChannelEntryRuntimeContract; - setupEntry?: BundledChannelSetupEntryRuntimeContract; }; type BundledChannelCacheContext = { pluginLoadInProgressIds: Set; setupPluginLoadInProgressIds: Set; entryLoadInProgressIds: Set; + setupEntryLoadInProgressIds: Set; lazyEntriesById: Map; + lazySetupEntriesById: Map; lazyPluginsById: Map; lazySetupPluginsById: Map; lazySecretsById: Map; @@ -102,7 +109,7 @@ function resolveChannelSetupModuleEntry( } function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | undefined, + entry: BundledChannelSetupEntryRuntimeContract | null | undefined, feature: keyof NonNullable, ): boolean { return entry?.features?.[feature] === true; @@ -186,7 +193,6 @@ function loadGeneratedBundledChannelModule(params: { function loadGeneratedBundledChannelEntry(params: { rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; - includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( @@ -202,20 +208,9 @@ function loadGeneratedBundledChannelEntry(params: { ); return null; } - const setupEntry = - params.includeSetup && params.metadata.setupSource - ? resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ) - : null; return { id: params.metadata.manifest.id, entry, - ...(setupEntry ? { setupEntry } : {}), }; } catch (error) { const detail = formatErrorMessage(error); @@ -224,6 +219,37 @@ function loadGeneratedBundledChannelEntry(params: { } } +function loadGeneratedBundledChannelSetupEntry(params: { + rootScope: BundledChannelRootScope; + metadata: BundledChannelPluginMetadata; +}): BundledChannelSetupEntryRuntimeContract | null { + if (!params.metadata.setupSource) { + return null; + } + try { + const setupEntry = resolveChannelSetupModuleEntry( + loadGeneratedBundledChannelModule({ + rootScope: params.rootScope, + metadata: params.metadata, + entry: params.metadata.setupSource, + }), + ); + if (!setupEntry) { + log.warn( + `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, + ); + return null; + } + return setupEntry; + } catch (error) { + const detail = formatErrorMessage(error); + log.warn( + `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + ); + return null; + } +} + const cachedBundledChannelMetadata = new Map(); const bundledChannelCacheContexts = new Map(); @@ -232,7 +258,9 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext { pluginLoadInProgressIds: new Set(), setupPluginLoadInProgressIds: new Set(), entryLoadInProgressIds: new Set(), + setupEntryLoadInProgressIds: new Set(), lazyEntriesById: new Map(), + lazySetupEntriesById: new Map(), lazyPluginsById: new Map(), lazySetupPluginsById: new Map(), lazySecretsById: new Map(), @@ -288,6 +316,17 @@ function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +function listBundledChannelPluginIdsForSetupFeature( + rootScope: BundledChannelRootScope, + feature: keyof NonNullable, +): readonly ChannelId[] { + const hinted = listBundledChannelMetadata(rootScope) + .filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true) + .map((metadata) => metadata.manifest.id) + .toSorted((left, right) => left.localeCompare(right)); + return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope); +} + export function listBundledChannelPluginIds(): readonly ChannelId[] { return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope()); } @@ -305,13 +344,12 @@ function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, - params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { const cached = cacheContext.lazyEntriesById.get(id); - if (cached && (!params?.includeSetup || cached.setupEntry)) { + if (cached) { return cached; } - if (cached === null && !params?.includeSetup) { + if (cached === null) { return null; } const metadata = resolveBundledChannelMetadata(id, rootScope); @@ -327,7 +365,6 @@ function getLazyGeneratedBundledChannelEntryForRoot( const entry = loadGeneratedBundledChannelEntry({ rootScope, metadata, - includeSetup: params?.includeSetup === true, }); cacheContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { @@ -339,6 +376,51 @@ function getLazyGeneratedBundledChannelEntryForRoot( } } +function cacheBundledChannelSetupEntry( + metadata: BundledChannelPluginMetadata, + cacheContext: BundledChannelCacheContext, + entry: BundledChannelSetupEntryRuntimeContract | null, + requestedId?: ChannelId, +) { + const ids = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + ...(requestedId ? [requestedId] : []), + ]); + for (const id of ids) { + cacheContext.lazySetupEntriesById.set(id, entry); + } +} + +function getLazyGeneratedBundledChannelSetupEntryForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): BundledChannelSetupEntryRuntimeContract | null { + if (cacheContext.lazySetupEntriesById.has(id)) { + return cacheContext.lazySetupEntriesById.get(id) ?? null; + } + const metadata = resolveBundledChannelMetadata(id, rootScope); + if (!metadata) { + cacheContext.lazySetupEntriesById.set(id, null); + return null; + } + if (cacheContext.setupEntryLoadInProgressIds.has(id)) { + return null; + } + cacheContext.setupEntryLoadInProgressIds.add(id); + try { + const setupEntry = loadGeneratedBundledChannelSetupEntry({ + rootScope, + metadata, + }); + cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id); + return setupEntry; + } finally { + cacheContext.setupEntryLoadInProgressIds.delete(id); + } +} + function getBundledChannelPluginForRoot( id: ChannelId, rootScope: BundledChannelRootScope, @@ -414,9 +496,7 @@ function getBundledChannelSetupPluginForRoot( if (cacheContext.setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -438,9 +518,7 @@ function getBundledChannelSetupSecretsForRoot( if (cacheContext.lazySetupSecretsById.has(id)) { return cacheContext.lazySetupSecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -471,10 +549,8 @@ export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } @@ -483,6 +559,52 @@ export function listBundledChannelSetupPluginsByFeature( }); } +export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const surface = setupEntry?.loadLegacySessionSurface?.(); + if (surface) { + return [surface]; + } + if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.messaging ? [plugin.messaging] : []; + }, + ); +} + +export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); + if (detector) { + return [detector]; + } + if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.lifecycle?.detectLegacyStateMigrations + ? [plugin.lifecycle.detectLegacyStateMigrations] + : []; + }, + ); +} + export function hasBundledChannelEntryFeature( id: ChannelId, feature: keyof NonNullable, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index f43d77e6350..91e30f5bf73 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js"; +import { + listBundledChannelLegacySessionSurfaces, + listBundledChannelLegacyStateMigrationDetectors, +} from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { resolveLegacyStateDirs, @@ -86,12 +89,7 @@ function getLegacySessionSurfaces(): LegacySessionSurface[] { // Legacy migrations run on cold doctor/startup paths. Prefer the narrower // setup plugin surface here so session-key cleanup does not materialize full // bundled channel runtimes. - cachedLegacySessionSurfaces ??= listBundledChannelSetupPluginsByFeature( - "legacySessionSurfaces", - ).flatMap((plugin) => { - const surface = plugin.messaging; - return surface && typeof surface === "object" ? [surface] : []; - }); + cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()]; return cachedLegacySessionSurfaces; } @@ -670,10 +668,11 @@ async function collectChannelLegacyStateMigrationPlans(params: { oauthDir: string; }): Promise { const plans: ChannelLegacyStateMigrationPlan[] = []; - // Legacy state detection belongs on the lightweight setup surface so doctor + // Legacy state detection belongs on a narrow setup-entry surface so doctor // does not cold-load unrelated runtime channel code. - for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) { - const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({ + const detectors = listBundledChannelLegacyStateMigrationDetectors(); + for (const detectLegacyStateMigrations of detectors) { + const detected = await detectLegacyStateMigrations({ cfg: params.cfg, env: params.env, stateDir: params.stateDir, diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index e62e6f19d5f..621f42afcf3 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js"; import type { ChannelConfigSchema } from "../channels/plugins/types.config.js"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { getCachedPluginJitiLoader, @@ -47,6 +49,8 @@ type DefineBundledChannelSetupEntryOptions = { plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; runtime?: BundledEntryModuleRef; + legacyStateMigrations?: BundledEntryModuleRef; + legacySessionSurface?: BundledEntryModuleRef; features?: BundledChannelSetupEntryFeatures; }; @@ -59,6 +63,25 @@ export type BundledChannelEntryFeatures = { accountInspect?: boolean; }; +export type BundledChannelLegacySessionSurface = { + isLegacyGroupSessionKey?: (key: string) => boolean; + canonicalizeLegacySessionKey?: (params: { + key: string; + agentId: string; + }) => string | null | undefined; +}; + +export type BundledChannelLegacyStateMigrationDetector = (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; +}) => + | ChannelLegacyStateMigrationPlan[] + | Promise + | null + | undefined; + export type BundledChannelEntryContract = { kind: "bundled-channel-entry"; id: string; @@ -77,6 +100,8 @@ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => TPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; setChannelRuntime?: (runtime: PluginRuntime) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -404,6 +429,8 @@ export function defineBundledChannelSetupEntry({ plugin, secrets, runtime, + legacyStateMigrations, + legacySessionSurface, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract { // Bundled setup entries stay on a light path during setup-only/setup-runtime loads. @@ -418,6 +445,20 @@ export function defineBundledChannelSetupEntry({ setter(pluginRuntime); } : undefined; + const loadLegacyStateMigrationDetector = legacyStateMigrations + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacyStateMigrations, + ) + : undefined; + const loadLegacySessionSurface = legacySessionSurface + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacySessionSurface, + ) + : undefined; return { kind: "bundled-channel-setup-entry", loadSetupPlugin: () => loadBundledEntryExportSync(importMetaUrl, plugin), @@ -430,6 +471,8 @@ export function defineBundledChannelSetupEntry({ ), } : {}), + ...(loadLegacyStateMigrationDetector ? { loadLegacyStateMigrationDetector } : {}), + ...(loadLegacySessionSurface ? { loadLegacySessionSurface } : {}), ...(setChannelRuntime ? { setChannelRuntime } : {}), ...(features ? { features } : {}), }; diff --git a/src/plugin-sdk/channel-pairing-paths.ts b/src/plugin-sdk/channel-pairing-paths.ts new file mode 100644 index 00000000000..2059b3086a7 --- /dev/null +++ b/src/plugin-sdk/channel-pairing-paths.ts @@ -0,0 +1 @@ +export { resolveChannelAllowFromPath } from "../pairing/allow-from-store-read.js"; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e7a0b8c9407..f8117aa5cfc 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -809,9 +809,15 @@ export type OpenClawPackageStartup = { deferConfiguredChannelFullLoadUntilAfterListen?: boolean; }; +export type OpenClawPackageSetupFeatures = { + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; + setupFeatures?: OpenClawPackageSetupFeatures; channel?: PluginPackageChannel; install?: PluginPackageInstall; startup?: OpenClawPackageStartup;