diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 6378cd1b82b..e1a8fae55f6 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,10 +1,6 @@ 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 { - resolveSingleAccountKeysToMove, - resolveSingleAccountPromotionTarget, -} from "./setup-promotion-helpers.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelSetupInput } from "./types.core.js"; @@ -14,6 +10,81 @@ type ChannelSectionBase = { accounts?: Record>; }; +const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ + "name", + "token", + "tokenFile", + "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", + "groupAllowFrom", + "defaultTo", + "streaming", + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "allowBots", + "blockStreaming", + "replyToMode", + "threadReplies", + "textChunkLimit", + "chunkMode", + "responsePrefix", + "ackReaction", + "ackReactionScope", + "reactionNotifications", + "threadBindings", + "startupVerification", + "startupVerificationCooldownHours", + "mediaMaxMb", + "autoJoin", + "autoJoinAllowlist", + "dm", + "groups", + "rooms", + "actions", +]); + +const NAMED_ACCOUNT_PROMOTION_KEYS_BY_CHANNEL: Record = { + matrix: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", + ], + telegram: ["botToken", "tokenFile"], +}; + function channelHasAccounts(cfg: OpenClawConfig, channelKey: string): boolean { const channels = cfg.channels as Record | undefined; const base = channels?.[channelKey] as ChannelSectionBase | undefined; @@ -427,6 +498,46 @@ function resolveExistingAccountKey( return targetAccountId; } +function resolveSingleAccountKeysToMove(params: { + channelKey: string; + channel: Record; +}): string[] { + const hasNamedAccounts = Object.keys( + (params.channel.accounts as Record) ?? {}, + ).some(Boolean); + const entries = Object.entries(params.channel) + .filter( + ([key, value]) => + key !== "accounts" && key !== "defaultAccount" && key !== "enabled" && value !== undefined, + ) + .map(([key]) => key); + const keysToMove = entries.filter((key) => COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(key)); + if (!hasNamedAccounts || keysToMove.length === 0) { + return keysToMove; + } + 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() + ? normalizeAccountId(params.channel.defaultAccount) + : undefined; + if (normalizedDefaultAccount) { + return ( + Object.keys(accounts).find( + (accountId) => normalizeAccountId(accountId) === normalizedDefaultAccount, + ) ?? DEFAULT_ACCOUNT_ID + ); + } + const namedAccounts = Object.keys(accounts).filter(Boolean); + return namedAccounts.length === 1 ? namedAccounts[0] : DEFAULT_ACCOUNT_ID; +} + // When promoting a single-account channel config to multi-account, // move top-level account settings into accounts.default so the original // account keeps working without duplicate account values at channel root. @@ -453,7 +564,6 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { } const targetAccountId = resolveSingleAccountPromotionTarget({ - channelKey: params.channelKey, channel: base, }); const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 634914ae79e..c7ea4e1d526 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -35,6 +35,14 @@ vi.mock("../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: mockLoadPluginManifestRegistry, })); +vi.mock("../plugins/plugin-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForPluginRegistry: mockLoadPluginManifestRegistry, + }; +}); + vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -804,7 +812,7 @@ describe("config io write", () => { }); }); - it("preserves parsed source config when snapshot validation throws", async () => { + it("preserves parsed source config when snapshot validation fails", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -814,10 +822,6 @@ describe("config io write", () => { }; const originalRaw = `${JSON.stringify(original, null, 2)}\n`; await fs.writeFile(configPath, originalRaw, "utf-8"); - mockLoadPluginManifestRegistry.mockImplementationOnce(() => { - throw new Error("manifest registry unavailable"); - }); - const io = createFastConfigIO(home); const snapshot = await io.readConfigFileSnapshot(); @@ -827,7 +831,7 @@ describe("config io write", () => { expect(snapshot.parsed).toEqual(original); expect(snapshot.sourceConfig).toEqual(original); expect(snapshot.config).toEqual(original); - expect(snapshot.issues[0]?.message).toContain("manifest registry unavailable"); + expect(snapshot.issues[0]?.message).toContain("unknown channel id: test-plugin-channel"); }); }); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 1e85d69bd7a..5a7b4e8fca4 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -155,7 +155,7 @@ describe("normalizePluginsConfig", () => { expect(result.entries.minimax?.enabled).toBe(false); }); - it("reuses the plugin alias discovery during one config normalization", async () => { + it("normalizes unknown plugin ids without loading discovery", async () => { vi.resetModules(); const discovery = await import("./discovery.js"); const discoverPlugins = vi.spyOn(discovery, "discoverOpenClawPlugins"); @@ -176,10 +176,10 @@ describe("normalizePluginsConfig", () => { expect(result.allow).toEqual(["unknown-plugin-one", "unknown-plugin-two"]); expect(result.deny).toEqual(["unknown-plugin-three"]); expect(result.entries["unknown-plugin-four"]?.enabled).toBe(true); - expect(discoverPlugins).toHaveBeenCalledTimes(1); + expect(discoverPlugins).not.toHaveBeenCalled(); }); - it("keeps alias lookup limited to bundled plugin manifests", async () => { + it("does not load discovery or manifests for alias lookup", async () => { vi.resetModules(); const discovery = await import("./discovery.js"); const manifest = await import("./manifest.js"); @@ -224,7 +224,7 @@ describe("normalizePluginsConfig", () => { }); expect(result.deny).toEqual(["anthropic"]); - expect(discoverPlugins).toHaveBeenCalledTimes(1); + expect(discoverPlugins).not.toHaveBeenCalled(); expect(loadManifest).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 5be3ad920ce..ae0edb12a63 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -20,8 +20,6 @@ import { type NormalizePluginId, type NormalizedPluginsConfig as SharedNormalizedPluginsConfig, } from "./config-normalization-shared.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; -import { loadPluginManifest } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -48,34 +46,6 @@ const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map([ function getBundledPluginAliasLookup(): ReadonlyMap { const lookup = new Map(); - for (const candidate of discoverOpenClawPlugins({}).candidates) { - if (candidate.origin !== "bundled") { - continue; - } - const manifestResult = candidate.bundledManifest - ? { ok: true as const, manifest: candidate.bundledManifest } - : loadPluginManifest(candidate.rootDir, false); - if (!manifestResult.ok) { - continue; - } - const manifest = manifestResult.manifest; - const pluginId = normalizeOptionalLowercaseString(manifest.id); - if (pluginId) { - lookup.set(pluginId, manifest.id); - } - for (const providerId of manifest.providers ?? []) { - const normalizedProviderId = normalizeOptionalLowercaseString(providerId); - if (normalizedProviderId) { - lookup.set(normalizedProviderId, manifest.id); - } - } - for (const legacyPluginId of manifest.legacyPluginIds ?? []) { - const normalizedLegacyPluginId = normalizeOptionalLowercaseString(legacyPluginId); - if (normalizedLegacyPluginId) { - lookup.set(normalizedLegacyPluginId, manifest.id); - } - } - } for (const [alias, pluginId] of BUILT_IN_PLUGIN_ALIAS_FALLBACKS) { lookup.set(alias, pluginId); } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5b15b81792b..34c8e8c44f9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1178,8 +1178,22 @@ function activatePluginRegistry( export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { const requestedOnlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); const requestedOnlyPluginIdSet = createPluginIdScopeSet(requestedOnlyPluginIds); - if (options.activate === false && requestedOnlyPluginIdSet?.size === 0) { - return createEmptyPluginRegistry(); + if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) { + const emptyRegistry = createEmptyPluginRegistry(); + if (options.activate !== false) { + clearAgentHarnesses(); + clearPluginCommands(); + clearPluginInteractiveHandlers(); + clearDetachedTaskLifecycleRuntimeRegistration(); + clearMemoryPluginState(); + activatePluginRegistry( + emptyRegistry, + `empty-plugin-scope::${resolveRuntimeSubagentMode(options.runtimeOptions)}::${options.workspaceDir ?? ""}`, + resolveRuntimeSubagentMode(options.runtimeOptions), + options.workspaceDir, + ); + } + return emptyRegistry; } const { @@ -1203,19 +1217,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const validateOnly = options.mode === "validate"; const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds); - if (onlyPluginIdSet && onlyPluginIdSet.size === 0) { - const emptyRegistry = createEmptyPluginRegistry(); - if (shouldActivate) { - clearAgentHarnesses(); - clearPluginCommands(); - clearPluginInteractiveHandlers(); - clearDetachedTaskLifecycleRuntimeRegistration(); - clearMemoryPluginState(); - activatePluginRegistry(emptyRegistry, cacheKey, runtimeSubagentMode, options.workspaceDir); - } - return emptyRegistry; - } - const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey);