diff --git a/src/channels/config-presence.test.ts b/src/channels/config-presence.test.ts index 769aaa7d1a2..c3063e79be5 100644 --- a/src/channels/config-presence.test.ts +++ b/src/channels/config-presence.test.ts @@ -37,9 +37,15 @@ function expectPotentialConfiguredChannelCase(params: { env: NodeJS.ProcessEnv; expectedIds: string[]; expectedConfigured: boolean; + options?: Parameters[2]; }) { - expect(listPotentialConfiguredChannelIds(params.cfg, params.env)).toEqual(params.expectedIds); - expect(hasPotentialConfiguredChannels(params.cfg, params.env)).toBe(params.expectedConfigured); + const options = params.options ?? {}; + expect(listPotentialConfiguredChannelIds(params.cfg, params.env, options)).toEqual( + params.expectedIds, + ); + expect(hasPotentialConfiguredChannels(params.cfg, params.env, options)).toBe( + params.expectedConfigured, + ); } afterEach(() => { @@ -68,6 +74,7 @@ describe("config presence", () => { env, expectedIds: [], expectedConfigured: false, + options: { includePersistedAuthState: false }, }); }); @@ -81,6 +88,7 @@ describe("config presence", () => { env, expectedIds: ["matrix"], expectedConfigured: true, + options: { includePersistedAuthState: false }, }); }); @@ -98,6 +106,12 @@ describe("config presence", () => { env, expectedIds: ["matrix"], expectedConfigured: true, + options: { + persistedAuthStateProbe: { + listChannelIds: () => ["matrix"], + hasState: () => true, + }, + }, }); }); }); diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index a2624182882..40ebbcae860 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -14,6 +14,14 @@ const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); type ChannelPresenceOptions = { includePersistedAuthState?: boolean; + persistedAuthStateProbe?: { + listChannelIds: () => readonly string[]; + hasState: (params: { + channelId: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + }) => boolean; + }; }; export function hasMeaningfulChannelConfig(value: unknown): boolean { @@ -36,13 +44,33 @@ function hasPersistedChannelState(env: NodeJS.ProcessEnv): boolean { return fs.existsSync(resolveStateDir(env, os.homedir)); } -let persistedAuthStateChannelIds: string[] | null = null; +let persistedAuthStateChannelIds: readonly string[] | null = null; -function getPersistedAuthStateChannelIds(): string[] { - persistedAuthStateChannelIds ??= listBundledChannelIdsWithPersistedAuthState(); +function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): readonly string[] { + const override = options.persistedAuthStateProbe?.listChannelIds(); + if (override) { + return override; + } + if (persistedAuthStateChannelIds) { + return persistedAuthStateChannelIds; + } + persistedAuthStateChannelIds = listBundledChannelIdsWithPersistedAuthState(); return persistedAuthStateChannelIds; } +function hasPersistedAuthState(params: { + channelId: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + options: ChannelPresenceOptions; +}): boolean { + const override = params.options.persistedAuthStateProbe; + if (override) { + return override.hasState(params); + } + return hasBundledChannelPersistedAuthState(params); +} + export function listPotentialConfiguredChannelIds( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -75,8 +103,8 @@ export function listPotentialConfiguredChannelIds( } if (options.includePersistedAuthState !== false && hasPersistedChannelState(env)) { - for (const channelId of getPersistedAuthStateChannelIds()) { - if (hasBundledChannelPersistedAuthState({ channelId, cfg, env })) { + for (const channelId of listPersistedAuthStateChannelIds(options)) { + if (hasPersistedAuthState({ channelId, cfg, env, options })) { configuredChannelIds.add(channelId); } } @@ -103,8 +131,8 @@ function hasEnvConfiguredChannel( if (options.includePersistedAuthState === false || !hasPersistedChannelState(env)) { return false; } - return getPersistedAuthStateChannelIds().some((channelId) => - hasBundledChannelPersistedAuthState({ channelId, cfg, env }), + return listPersistedAuthStateChannelIds(options).some((channelId) => + hasPersistedAuthState({ channelId, cfg, env, options }), ); } diff --git a/src/channels/plugins/acp-bindings.test.ts b/src/channels/plugins/acp-bindings.test.ts index 0a9fec4757d..6f9c6da2cba 100644 --- a/src/channels/plugins/acp-bindings.test.ts +++ b/src/channels/plugins/acp-bindings.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js"; +import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js"; +import * as bindingRegistry from "./configured-binding-registry.js"; const resolveAgentConfigMock = vi.hoisted(() => vi.fn()); const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); @@ -23,12 +25,6 @@ vi.mock("../../plugins/runtime.js", () => ({ requireActivePluginChannelRegistry: requireActivePluginChannelRegistryMock, })); -async function importConfiguredBindings() { - const builtins = await import("./configured-binding-builtins.js"); - builtins.ensureConfiguredBindingBuiltinsRegistered(); - return await import("./configured-binding-registry.js"); -} - function createConfig(options?: { bindingAgentId?: string; accountId?: string }) { return { agents: { @@ -95,19 +91,18 @@ function createDiscordAcpPlugin(overrides?: { describe("configured binding registry", () => { beforeEach(() => { - vi.resetModules(); resolveAgentConfigMock.mockReset().mockReturnValue(undefined); resolveDefaultAgentIdMock.mockReset().mockReturnValue("main"); resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace"); getChannelPluginMock.mockReset(); getActivePluginChannelRegistryVersionMock.mockReset().mockReturnValue(1); requireActivePluginChannelRegistryMock.mockReset().mockReturnValue({}); + ensureConfiguredBindingBuiltinsRegistered(); }); it("resolves configured ACP bindings from an already loaded channel plugin", async () => { const plugin = createDiscordAcpPlugin(); getChannelPluginMock.mockReturnValue(plugin); - const bindingRegistry = await importConfiguredBindings(); const resolved = bindingRegistry.resolveConfiguredBindingRecord({ cfg: createConfig() as never, @@ -124,7 +119,6 @@ describe("configured binding registry", () => { it("resolves configured ACP bindings from canonical conversation refs", async () => { const plugin = createDiscordAcpPlugin(); getChannelPluginMock.mockReturnValue(plugin); - const bindingRegistry = await importConfiguredBindings(); const resolved = bindingRegistry.resolveConfiguredBinding({ cfg: createConfig() as never, @@ -154,7 +148,6 @@ describe("configured binding registry", () => { const plugin = createDiscordAcpPlugin(); const cfg = createConfig({ bindingAgentId: "codex" }); getChannelPluginMock.mockReturnValue(plugin); - const bindingRegistry = await importConfiguredBindings(); const primed = bindingRegistry.primeConfiguredBindingRegistry({ cfg: cfg as never, @@ -183,7 +176,6 @@ describe("configured binding registry", () => { it("resolves wildcard binding session keys from the compiled registry", async () => { const plugin = createDiscordAcpPlugin(); getChannelPluginMock.mockReturnValue(plugin); - const bindingRegistry = await importConfiguredBindings(); const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({ cfg: createConfig({ accountId: "*" }) as never, @@ -203,8 +195,6 @@ describe("configured binding registry", () => { }); it("does not perform late plugin discovery when a channel plugin is unavailable", async () => { - const bindingRegistry = await importConfiguredBindings(); - const resolved = bindingRegistry.resolveConfiguredBindingRecord({ cfg: createConfig() as never, channel: "discord", @@ -220,7 +210,6 @@ describe("configured binding registry", () => { getChannelPluginMock.mockReturnValue(plugin); getActivePluginChannelRegistryVersionMock.mockReturnValue(10); const cfg = createConfig(); - const bindingRegistry = await importConfiguredBindings(); bindingRegistry.resolveConfiguredBindingRecord({ cfg: cfg as never, diff --git a/src/channels/plugins/binding-targets.ts b/src/channels/plugins/binding-targets.ts index 9a871fe8108..fda5b08bb45 100644 --- a/src/channels/plugins/binding-targets.ts +++ b/src/channels/plugins/binding-targets.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ConfiguredBindingResolution } from "./binding-types.js"; -import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js"; +import { + ensureStatefulTargetBuiltinsRegistered, + isStatefulTargetBuiltinDriverId, +} from "./stateful-target-builtins.js"; import { getStatefulBindingTargetDriver, resolveStatefulBindingTargetBySessionKey, @@ -10,15 +13,19 @@ export async function ensureConfiguredBindingTargetReady(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution | null; }): Promise<{ ok: true } | { ok: false; error: string }> { - await ensureStatefulTargetBuiltinsRegistered(); if (!params.bindingResolution) { return { ok: true }; } - const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + const driverId = params.bindingResolution.statefulTarget.driverId; + let driver = getStatefulBindingTargetDriver(driverId); + if (!driver && isStatefulTargetBuiltinDriverId(driverId)) { + await ensureStatefulTargetBuiltinsRegistered(); + driver = getStatefulBindingTargetDriver(driverId); + } if (!driver) { return { ok: false, - error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + error: `Configured binding target driver unavailable: ${driverId}`, }; } return await driver.ensureReady({ @@ -33,11 +40,17 @@ export async function resetConfiguredBindingTargetInPlace(params: { reason: "new" | "reset"; commandSource?: string; }): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> { - await ensureStatefulTargetBuiltinsRegistered(); - const resolved = resolveStatefulBindingTargetBySessionKey({ + let resolved = resolveStatefulBindingTargetBySessionKey({ cfg: params.cfg, sessionKey: params.sessionKey, }); + if (!resolved) { + await ensureStatefulTargetBuiltinsRegistered(); + resolved = resolveStatefulBindingTargetBySessionKey({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + } if (!resolved?.driver.resetInPlace) { return { ok: false, @@ -54,13 +67,17 @@ export async function ensureConfiguredBindingTargetSession(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution; }): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> { - await ensureStatefulTargetBuiltinsRegistered(); - const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId); + const driverId = params.bindingResolution.statefulTarget.driverId; + let driver = getStatefulBindingTargetDriver(driverId); + if (!driver && isStatefulTargetBuiltinDriverId(driverId)) { + await ensureStatefulTargetBuiltinsRegistered(); + driver = getStatefulBindingTargetDriver(driverId); + } if (!driver) { return { ok: false, sessionKey: params.bindingResolution.statefulTarget.sessionKey, - error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`, + error: `Configured binding target driver unavailable: ${driverId}`, }; } return await driver.ensureSession({ diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 156d0b220d3..357c4fe05dd 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -141,162 +141,140 @@ function loadGeneratedBundledChannelModule(params: { }); } -function loadGeneratedBundledChannelEntries(): readonly GeneratedBundledChannelEntry[] { - const entries: GeneratedBundledChannelEntry[] = []; - - for (const metadata of listBundledChannelPluginMetadata({ - includeChannelConfigs: false, - includeSyntheticChannelConfigs: false, - })) { - if ((metadata.manifest.channels?.length ?? 0) === 0) { - continue; - } - - try { - const entry = resolveChannelPluginModuleEntry( - loadGeneratedBundledChannelModule({ - metadata, - entry: metadata.source, - }), +function loadGeneratedBundledChannelEntry(params: { + metadata: BundledChannelPluginMetadata; + includeSetup: boolean; +}): GeneratedBundledChannelEntry | null { + try { + const entry = resolveChannelPluginModuleEntry( + loadGeneratedBundledChannelModule({ + metadata: params.metadata, + entry: params.metadata.source, + }), + ); + if (!entry) { + log.warn( + `[channels] bundled channel entry ${params.metadata.manifest.id} missing bundled-channel-entry contract; skipping`, ); - if (!entry) { - log.warn( - `[channels] bundled channel entry ${metadata.manifest.id} missing bundled-channel-entry contract; skipping`, - ); - continue; - } - const setupEntry = metadata.setupSource + return null; + } + const setupEntry = + params.includeSetup && params.metadata.setupSource ? resolveChannelSetupModuleEntry( loadGeneratedBundledChannelModule({ - metadata, - entry: metadata.setupSource, + metadata: params.metadata, + entry: params.metadata.setupSource, }), ) : null; - entries.push({ - id: metadata.manifest.id, - entry, - ...(setupEntry ? { setupEntry } : {}), - }); - } catch (error) { - const detail = formatErrorMessage(error); - log.warn(`[channels] failed to load bundled channel ${metadata.manifest.id}: ${detail}`); - } + return { + id: params.metadata.manifest.id, + entry, + ...(setupEntry ? { setupEntry } : {}), + }; + } catch (error) { + const detail = formatErrorMessage(error); + log.warn(`[channels] failed to load bundled channel ${params.metadata.manifest.id}: ${detail}`); + return null; } - - return entries; } -type BundledChannelState = { - entries: readonly GeneratedBundledChannelEntry[]; - entriesById: Map; - setupEntriesById: Map; - sortedIds: readonly ChannelId[]; - pluginsById: Map; - setupPluginsById: Map; - secretsById: Map; - setupSecretsById: Map; - runtimeSettersById: Map>; -}; +let cachedBundledChannelMetadata: readonly BundledChannelPluginMetadata[] | null = null; -const EMPTY_BUNDLED_CHANNEL_STATE: BundledChannelState = { - entries: [], - entriesById: new Map(), - setupEntriesById: new Map(), - sortedIds: [], - pluginsById: new Map(), - setupPluginsById: new Map(), - secretsById: new Map(), - setupSecretsById: new Map(), - runtimeSettersById: new Map(), -}; +function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] { + cachedBundledChannelMetadata ??= listBundledChannelPluginMetadata({ + includeChannelConfigs: false, + includeSyntheticChannelConfigs: false, + }).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0); + return cachedBundledChannelMetadata; +} + +export function listBundledChannelPluginIds(): readonly ChannelId[] { + return listBundledChannelMetadata() + .map((metadata) => metadata.manifest.id) + .toSorted((left, right) => left.localeCompare(right)); +} -let cachedBundledChannelState: BundledChannelState | null = null; -let bundledChannelStateLoadInProgress = false; const pluginLoadInProgressIds = new Set(); const setupPluginLoadInProgressIds = new Set(); +const entryLoadInProgressIds = new Set(); +const lazyEntriesById = new Map(); +const lazyPluginsById = new Map(); +const lazySetupPluginsById = new Map(); +const lazySecretsById = new Map(); +const lazySetupSecretsById = new Map(); -function getBundledChannelState(): BundledChannelState { - if (cachedBundledChannelState) { - return cachedBundledChannelState; - } - if (bundledChannelStateLoadInProgress) { - return EMPTY_BUNDLED_CHANNEL_STATE; - } - bundledChannelStateLoadInProgress = true; - const entries = loadGeneratedBundledChannelEntries(); - const entriesById = new Map(); - const setupEntriesById = new Map(); - const runtimeSettersById = new Map< - ChannelId, - NonNullable - >(); - for (const { entry } of entries) { - if (entriesById.has(entry.id)) { - throw new Error(`duplicate bundled channel plugin id: ${entry.id}`); - } - entriesById.set(entry.id, entry); - if (entry.setChannelRuntime) { - runtimeSettersById.set(entry.id, entry.setChannelRuntime); - } - } - for (const { id, setupEntry } of entries) { - if (setupEntry) { - setupEntriesById.set(id, setupEntry); - } - } +function resolveBundledChannelMetadata(id: ChannelId): BundledChannelPluginMetadata | undefined { + return listBundledChannelMetadata().find( + (metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id), + ); +} +function getLazyGeneratedBundledChannelEntry( + id: ChannelId, + params?: { includeSetup?: boolean }, +): GeneratedBundledChannelEntry | null { + const cached = lazyEntriesById.get(id); + if (cached && (!params?.includeSetup || cached.setupEntry)) { + return cached; + } + if (cached === null && !params?.includeSetup) { + return null; + } + const metadata = resolveBundledChannelMetadata(id); + if (!metadata) { + lazyEntriesById.set(id, null); + return null; + } + if (entryLoadInProgressIds.has(id)) { + return null; + } + entryLoadInProgressIds.add(id); try { - cachedBundledChannelState = { - entries, - entriesById, - setupEntriesById, - sortedIds: [...entriesById.keys()].toSorted((left, right) => left.localeCompare(right)), - pluginsById: new Map(), - setupPluginsById: new Map(), - secretsById: new Map(), - setupSecretsById: new Map(), - runtimeSettersById, - }; - return cachedBundledChannelState; + const entry = loadGeneratedBundledChannelEntry({ + metadata, + includeSetup: params?.includeSetup === true, + }); + lazyEntriesById.set(id, entry); + if (entry?.entry.id && entry.entry.id !== id) { + lazyEntriesById.set(entry.entry.id, entry); + } + return entry; } finally { - bundledChannelStateLoadInProgress = false; + entryLoadInProgressIds.delete(id); } } export function listBundledChannelPlugins(): readonly ChannelPlugin[] { - const state = getBundledChannelState(); - return state.sortedIds.flatMap((id) => { + return listBundledChannelPluginIds().flatMap((id) => { const plugin = getBundledChannelPlugin(id); return plugin ? [plugin] : []; }); } export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { - const state = getBundledChannelState(); - return state.sortedIds.flatMap((id) => { + return listBundledChannelPluginIds().flatMap((id) => { const plugin = getBundledChannelSetupPlugin(id); return plugin ? [plugin] : []; }); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const state = getBundledChannelState(); - const cached = state.pluginsById.get(id); + const cached = lazyPluginsById.get(id); if (cached) { return cached; } if (pluginLoadInProgressIds.has(id)) { return undefined; } - const entry = state.entriesById.get(id); + const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; if (!entry) { return undefined; } pluginLoadInProgressIds.add(id); try { const plugin = entry.loadChannelPlugin(); - state.pluginsById.set(id, plugin); + lazyPluginsById.set(id, plugin); return plugin; } finally { pluginLoadInProgressIds.delete(id); @@ -304,36 +282,34 @@ export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefine } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const state = getBundledChannelState(); - if (state.secretsById.has(id)) { - return state.secretsById.get(id) ?? undefined; + if (lazySecretsById.has(id)) { + return lazySecretsById.get(id) ?? undefined; } - const entry = state.entriesById.get(id); + const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; if (!entry) { return undefined; } const secrets = entry.loadChannelSecrets?.() ?? getBundledChannelPlugin(id)?.secrets; - state.secretsById.set(id, secrets ?? null); + lazySecretsById.set(id, secrets ?? null); return secrets; } export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { - const state = getBundledChannelState(); - const cached = state.setupPluginsById.get(id); + const cached = lazySetupPluginsById.get(id); if (cached) { return cached; } if (setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = state.setupEntriesById.get(id); + const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; if (!entry) { return undefined; } setupPluginLoadInProgressIds.add(id); try { const plugin = entry.loadSetupPlugin(); - state.setupPluginsById.set(id, plugin); + lazySetupPluginsById.set(id, plugin); return plugin; } finally { setupPluginLoadInProgressIds.delete(id); @@ -341,16 +317,15 @@ export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | und } export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const state = getBundledChannelState(); - if (state.setupSecretsById.has(id)) { - return state.setupSecretsById.get(id) ?? undefined; + if (lazySetupSecretsById.has(id)) { + return lazySetupSecretsById.get(id) ?? undefined; } - const entry = state.setupEntriesById.get(id); + const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; if (!entry) { return undefined; } const secrets = entry.loadSetupSecrets?.() ?? getBundledChannelSetupPlugin(id)?.secrets; - state.setupSecretsById.set(id, secrets ?? null); + lazySetupSecretsById.set(id, secrets ?? null); return secrets; } @@ -363,7 +338,7 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { - const setter = getBundledChannelState().runtimeSettersById.get(id); + const setter = getLazyGeneratedBundledChannelEntry(id)?.entry.setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); } diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index de850e9d8d6..1a339ab47fb 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -1,13 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { - namedAccountPromotionKeys as matrixNamedAccountPromotionKeys, - resolveSingleAccountPromotionTarget as resolveMatrixSingleAccountPromotionTarget, - singleAccountKeysToMove as matrixSingleAccountKeysToMove, -} from "../../plugin-sdk/matrix.js"; -import { singleAccountKeysToMove as telegramSingleAccountKeysToMove } from "../../plugin-sdk/telegram.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { createChannelTestPluginBase, createTestRegistry, @@ -25,7 +19,41 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } -beforeEach(() => { +const matrixSingleAccountKeysToMove = [ + "allowBots", + "deviceId", + "deviceName", + "encryption", +] as const; +const matrixNamedAccountPromotionKeys = [ + "accessToken", + "deviceId", + "deviceName", + "encryption", + "homeserver", + "userId", +] as const; +const telegramSingleAccountKeysToMove = ["streaming"] as const; + +function resolveMatrixSingleAccountPromotionTarget(params: { + channel: { defaultAccount?: string; accounts?: Record }; +}): string { + const accounts = params.channel.accounts ?? {}; + const normalizedDefaultAccount = 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; +} + +beforeAll(() => { setActivePluginRegistry( createTestRegistry([ { @@ -54,7 +82,7 @@ beforeEach(() => { ); }); -afterEach(() => { +afterAll(() => { clearSetupPromotionRuntimeModuleCache(); resetPluginRuntimeStateForTest(); }); diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index ef7a9b1e01c..9c55da25190 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -1,17 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveSetupWizardAllowFromEntries, resolveSetupWizardGroupAllowlist, } from "../../../test/helpers/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { - namedAccountPromotionKeys as matrixNamedAccountPromotionKeys, - resolveSingleAccountPromotionTarget as resolveMatrixSingleAccountPromotionTarget, - singleAccountKeysToMove as matrixSingleAccountKeysToMove, -} from "../../plugin-sdk/matrix.js"; -import { singleAccountKeysToMove as telegramSingleAccountKeysToMove } from "../../plugin-sdk/telegram.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { createChannelTestPluginBase, createTestRegistry, @@ -71,7 +65,44 @@ import { splitSetupEntries, } from "./setup-wizard-helpers.js"; -beforeEach(() => { +const matrixSingleAccountKeysToMove = [ + "allowBots", + "deviceId", + "deviceName", + "dm", + "encryption", + "groups", + "rooms", +] as const; +const matrixNamedAccountPromotionKeys = [ + "accessToken", + "deviceId", + "deviceName", + "encryption", + "homeserver", + "userId", +] as const; +const telegramSingleAccountKeysToMove = ["streaming"] as const; + +function resolveMatrixSingleAccountPromotionTarget(params: { + channel: { defaultAccount?: string; accounts?: Record }; +}): string { + const accounts = params.channel.accounts ?? {}; + const normalizedDefaultAccount = 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; +} + +beforeAll(() => { setActivePluginRegistry( createTestRegistry([ { @@ -100,7 +131,7 @@ beforeEach(() => { ); }); -afterEach(() => { +afterAll(() => { resetPluginRuntimeStateForTest(); }); diff --git a/src/channels/plugins/stateful-target-builtins.ts b/src/channels/plugins/stateful-target-builtins.ts index 31b8144498e..2c9cc45acf8 100644 --- a/src/channels/plugins/stateful-target-builtins.ts +++ b/src/channels/plugins/stateful-target-builtins.ts @@ -5,6 +5,10 @@ import { let builtinsRegisteredPromise: Promise | null = null; +export function isStatefulTargetBuiltinDriverId(id: string): boolean { + return id.trim() === "acp"; +} + export async function ensureStatefulTargetBuiltinsRegistered(): Promise { if (builtinsRegisteredPromise) { await builtinsRegisteredPromise; diff --git a/src/config/bundled-channel-config-runtime.test.ts b/src/config/bundled-channel-config-runtime.test.ts index 39147c6b7e9..2d186ab8298 100644 --- a/src/config/bundled-channel-config-runtime.test.ts +++ b/src/config/bundled-channel-config-runtime.test.ts @@ -22,9 +22,26 @@ vi.mock("../plugins/bundled-plugin-metadata.js", () => ({ describe("bundled channel config runtime", () => { beforeEach(() => { vi.doUnmock("../channels/plugins/bundled.js"); + vi.doUnmock("../plugins/bundled-plugin-metadata.js"); }); + function mockBundledPluginMetadata() { + vi.doMock("../plugins/bundled-plugin-metadata.js", () => ({ + listBundledPluginMetadata: () => [ + { + manifest: { + channelConfigs: { + msteams: { schema: { type: "object" }, runtime: {} }, + whatsapp: { schema: { type: "object" } }, + }, + }, + }, + ], + })); + } + it("tolerates an unavailable bundled channel list during import", async () => { + mockBundledPluginMetadata(); vi.doMock("../channels/plugins/bundled.js", () => ({ listBundledChannelPlugins: () => undefined, })); @@ -41,6 +58,7 @@ describe("bundled channel config runtime", () => { }); it("falls back to static channel schemas when bundled plugin access hits a TDZ-style ReferenceError", async () => { + mockBundledPluginMetadata(); vi.doMock("../channels/plugins/bundled.js", () => { return { listBundledChannelPlugins() { diff --git a/src/config/config.agent-concurrency-defaults.test.ts b/src/config/config.agent-concurrency-defaults.test.ts index aa707e75b1c..f5bda63b0ed 100644 --- a/src/config/config.agent-concurrency-defaults.test.ts +++ b/src/config/config.agent-concurrency-defaults.test.ts @@ -5,8 +5,7 @@ import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent, } from "./agent-limits.js"; -import { loadConfig } from "./config.js"; -import { withTempHome, writeOpenClawConfig } from "./test-helpers.js"; +import { applyAgentDefaults } from "./defaults.js"; import { OpenClawSchema } from "./zod-schema.js"; describe("agent concurrency defaults", () => { @@ -44,14 +43,10 @@ describe("agent concurrency defaults", () => { expect(parsed.agents?.defaults?.subagents?.maxChildrenPerAgent).toBe(7); }); - it("injects defaults on load", async () => { - await withTempHome(async (home) => { - await writeOpenClawConfig(home, {}); + it("injects missing agent defaults", () => { + const cfg = applyAgentDefaults({}); - const cfg = loadConfig(); - - expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); - expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); - }); + expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); + expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); }); }); diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index 14e1ff05bdf..7065004554a 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./validation.js"; import { BlueBubblesConfigSchema, DiscordConfigSchema, @@ -11,49 +10,23 @@ import { } from "./zod-schema.providers-core.js"; import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js"; -const providerSchemas = { - bluebubbles: BlueBubblesConfigSchema, - discord: DiscordConfigSchema, - imessage: IMessageConfigSchema, - irc: IrcConfigSchema, - signal: SignalConfigSchema, - slack: SlackConfigSchema, - telegram: TelegramConfigSchema, - whatsapp: WhatsAppConfigSchema, -} as const; - -function expectChannelAllowlistIssue( - result: ReturnType, +function expectSchemaAllowlistIssue( + schema: { + safeParse: ( + value: unknown, + ) => + | { success: true; data: unknown } + | { success: false; error: { issues: Array<{ path: PropertyKey[] }> } }; + }, + config: unknown, path: string | readonly string[], ) { - expect(result.ok).toBe(false); - if (!result.ok) { - const pathParts = Array.isArray(path) ? path : [path]; - expect( - result.issues.some((issue) => pathParts.every((part) => issue.path.includes(part))), - ).toBe(true); - } -} - -function expectSchemaAllowlistIssue(params: { - schema: { safeParse: (value: unknown) => { success: true } | { success: false; error: unknown } }; - config: unknown; - path: string | readonly string[]; -}) { - const result = params.schema.safeParse(params.config); + const result = schema.safeParse(config); expect(result.success).toBe(false); if (!result.success) { - const pathParts = Array.isArray(params.path) ? params.path : [params.path]; - const issues = - (result.error as { issues?: Array<{ path?: Array }> }).issues ?? []; - const expectedParts = pathParts - .map((part) => part.replace(/^channels\.[^.]+\.?/u, "")) - .filter(Boolean); + const pathParts = Array.isArray(path) ? path : [path]; expect( - issues.some((issue) => { - const issuePath = issue.path?.join(".") ?? ""; - return expectedParts.every((part) => issuePath.includes(part)); - }), + result.error.issues.some((issue) => pathParts.every((part) => issue.path.includes(part))), ).toBe(true); } } @@ -62,34 +35,32 @@ describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { it.each([ { name: "telegram", - config: { telegram: { dmPolicy: "allowlist", botToken: "fake" } }, - issuePath: "channels.telegram.allowFrom", + schema: TelegramConfigSchema, + config: { dmPolicy: "allowlist", botToken: "fake" }, + issuePath: "allowFrom", }, { name: "signal", - config: { signal: { dmPolicy: "allowlist" } }, - issuePath: "channels.signal.allowFrom", + schema: SignalConfigSchema, + config: { dmPolicy: "allowlist" }, + issuePath: "allowFrom", }, { name: "discord", - config: { discord: { dmPolicy: "allowlist" } }, - issuePath: ["channels.discord", "allowFrom"], + schema: DiscordConfigSchema, + config: { dmPolicy: "allowlist" }, + issuePath: "allowFrom", }, { name: "whatsapp", - config: { whatsapp: { dmPolicy: "allowlist" } }, - issuePath: "channels.whatsapp.allowFrom", + schema: WhatsAppConfigSchema, + config: { dmPolicy: "allowlist" }, + issuePath: "allowFrom", }, ] as const)( 'rejects $name dmPolicy="allowlist" without allowFrom', - ({ name, config, issuePath }) => { - const providerConfig = config[name]; - const schema = providerSchemas[name as keyof typeof providerSchemas]; - if (schema) { - expectSchemaAllowlistIssue({ schema, config: providerConfig, path: issuePath }); - return; - } - expectChannelAllowlistIssue(validateConfigObject({ channels: config }), issuePath); + ({ schema, config, issuePath }) => { + expectSchemaAllowlistIssue(schema, config, issuePath); }, ); @@ -103,80 +74,66 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { it.each([ { name: "telegram", + schema: TelegramConfigSchema, config: { - telegram: { - allowFrom: ["12345"], - accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, - }, + allowFrom: ["12345"], + accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, }, }, { name: "signal", - config: { - signal: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: SignalConfigSchema, + config: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, }, { name: "discord", - config: { - discord: { allowFrom: ["123456789"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: DiscordConfigSchema, + config: { allowFrom: ["123456789"], accounts: { work: { dmPolicy: "allowlist" } } }, }, { name: "slack", + schema: SlackConfigSchema, config: { - slack: { - allowFrom: ["U123"], - botToken: "xoxb-top", - appToken: "xapp-top", - accounts: { - work: { dmPolicy: "allowlist", botToken: "xoxb-work", appToken: "xapp-work" }, - }, + allowFrom: ["U123"], + botToken: "xoxb-top", + appToken: "xapp-top", + accounts: { + work: { dmPolicy: "allowlist", botToken: "xoxb-work", appToken: "xapp-work" }, }, }, }, { name: "whatsapp", - config: { - whatsapp: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: WhatsAppConfigSchema, + config: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, }, { name: "imessage", - config: { - imessage: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: IMessageConfigSchema, + config: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, }, { name: "irc", - config: { - irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: IrcConfigSchema, + config: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } }, }, { name: "bluebubbles", - config: { - bluebubbles: { allowFrom: ["sender"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, + schema: BlueBubblesConfigSchema, + config: { allowFrom: ["sender"], accounts: { work: { dmPolicy: "allowlist" } } }, }, ] as const)( "accepts $name account allowlist when parent allowFrom exists", - ({ name, config }) => { - const providerConfig = config[name]; - const schema = providerSchemas[name]; - if (schema) { - expect(schema.safeParse(providerConfig).success).toBe(true); - return; - } - expect(validateConfigObject({ channels: config }).ok).toBe(true); + ({ schema, config }) => { + expect(schema.safeParse(config).success).toBe(true); }, ); it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => { - expectSchemaAllowlistIssue({ - schema: TelegramConfigSchema, - config: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } }, - path: "accounts.bot1.allowFrom", - }); + expectSchemaAllowlistIssue( + TelegramConfigSchema, + { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } }, + "allowFrom", + ); }); }); diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index 7f86ea561aa..af9d4fe867a 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -1,121 +1,88 @@ import { describe, expect, it } from "vitest"; import { applyCompactionDefaults } from "./defaults.js"; import type { OpenClawConfig } from "./types.js"; -import { OpenClawSchema } from "./zod-schema.js"; -function parseConfig(config: unknown): OpenClawConfig { - const result = OpenClawSchema.safeParse(config); - expect(result.success).toBe(true); - if (!result.success) { - throw new Error("expected config to parse"); - } - return result.data as OpenClawConfig; -} - -function parseConfigWithCompactionDefaults(config: unknown): OpenClawConfig { - return applyCompactionDefaults(parseConfig(config)); +function materializeCompactionConfig( + compaction: NonNullable["defaults"]>["compaction"], +) { + const cfg = applyCompactionDefaults({ + agents: { + defaults: { + compaction, + }, + }, + }); + return cfg.agents?.defaults?.compaction; } describe("config compaction settings", () => { - it("preserves memory flush config values", async () => { - const cfg = parseConfig({ - agents: { - defaults: { - compaction: { - mode: "safeguard", - reserveTokensFloor: 12_345, - identifierPolicy: "custom", - identifierInstructions: "Keep ticket IDs unchanged.", - qualityGuard: { - enabled: true, - maxRetries: 2, - }, - memoryFlush: { - enabled: false, - softThresholdTokens: 1234, - prompt: "Write notes.", - systemPrompt: "Flush memory now.", - }, - }, - }, + it("preserves memory flush config values", () => { + const compaction = materializeCompactionConfig({ + mode: "safeguard", + reserveTokensFloor: 12_345, + identifierPolicy: "custom", + identifierInstructions: "Keep ticket IDs unchanged.", + qualityGuard: { + enabled: true, + maxRetries: 2, + }, + memoryFlush: { + enabled: false, + softThresholdTokens: 1234, + prompt: "Write notes.", + systemPrompt: "Flush memory now.", }, }); - expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345); - expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard"); - expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBeUndefined(); - expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBeUndefined(); - expect(cfg.agents?.defaults?.compaction?.identifierPolicy).toBe("custom"); - expect(cfg.agents?.defaults?.compaction?.identifierInstructions).toBe( - "Keep ticket IDs unchanged.", - ); - expect(cfg.agents?.defaults?.compaction?.qualityGuard?.enabled).toBe(true); - expect(cfg.agents?.defaults?.compaction?.qualityGuard?.maxRetries).toBe(2); - expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(false); - expect(cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens).toBe(1234); - expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe("Write notes."); - expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe("Flush memory now."); + expect(compaction?.reserveTokensFloor).toBe(12_345); + expect(compaction?.mode).toBe("safeguard"); + expect(compaction?.reserveTokens).toBeUndefined(); + expect(compaction?.keepRecentTokens).toBeUndefined(); + expect(compaction?.identifierPolicy).toBe("custom"); + expect(compaction?.identifierInstructions).toBe("Keep ticket IDs unchanged."); + expect(compaction?.qualityGuard?.enabled).toBe(true); + expect(compaction?.qualityGuard?.maxRetries).toBe(2); + expect(compaction?.memoryFlush?.enabled).toBe(false); + expect(compaction?.memoryFlush?.softThresholdTokens).toBe(1234); + expect(compaction?.memoryFlush?.prompt).toBe("Write notes."); + expect(compaction?.memoryFlush?.systemPrompt).toBe("Flush memory now."); }); - it("preserves pi compaction override values", async () => { - const cfg = parseConfig({ - agents: { - defaults: { - compaction: { - reserveTokens: 15_000, - keepRecentTokens: 12_000, - }, - }, - }, + it("preserves pi compaction override values", () => { + const compaction = materializeCompactionConfig({ + reserveTokens: 15_000, + keepRecentTokens: 12_000, }); - expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBe(15_000); - expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBe(12_000); + expect(compaction?.reserveTokens).toBe(15_000); + expect(compaction?.keepRecentTokens).toBe(12_000); }); - it("defaults compaction mode to safeguard", async () => { - const cfg = parseConfigWithCompactionDefaults({ - agents: { - defaults: { - compaction: { - reserveTokensFloor: 9000, - }, - }, - }, + it("defaults compaction mode to safeguard", () => { + const compaction = materializeCompactionConfig({ + reserveTokensFloor: 9000, }); - expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard"); - expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(9000); + expect(compaction?.mode).toBe("safeguard"); + expect(compaction?.reserveTokensFloor).toBe(9000); }); - it("preserves recent turn safeguard values through schema parsing", async () => { - const cfg = parseConfig({ - agents: { - defaults: { - compaction: { - mode: "safeguard", - recentTurnsPreserve: 4, - }, - }, - }, + it("preserves recent turn safeguard values during materialization", () => { + const compaction = materializeCompactionConfig({ + mode: "safeguard", + recentTurnsPreserve: 4, }); - expect(cfg.agents?.defaults?.compaction?.recentTurnsPreserve).toBe(4); + expect(compaction?.recentTurnsPreserve).toBe(4); }); - it("preserves oversized quality guard retry values for runtime clamping", async () => { - const cfg = parseConfig({ - agents: { - defaults: { - compaction: { - qualityGuard: { - maxRetries: 99, - }, - }, - }, + it("preserves oversized quality guard retry values for runtime clamping", () => { + const compaction = materializeCompactionConfig({ + qualityGuard: { + maxRetries: 99, }, }); - expect(cfg.agents?.defaults?.compaction?.qualityGuard?.maxRetries).toBe(99); + expect(compaction?.qualityGuard?.maxRetries).toBe(99); }); }); diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 2bdeb297246..f8c0a3f16b4 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -24,57 +24,27 @@ function expectSchemaConfigValue(params: { expect(params.readValue(res.data)).toBe(params.expectedValue); } -function expectProviderValidationIssuePath(params: { - provider: string; - config: unknown; - expectedPath: string; -}) { - const res = validateConfigObject({ - channels: { - [params.provider]: params.config, - }, - }); - expect(res.ok, params.provider).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path, params.provider).toBe(params.expectedPath); - } -} - -function expectProviderSchemaValidationIssuePath(params: { - schema: { safeParse: (value: unknown) => { success: true } | { success: false; error: unknown } }; +function expectSchemaValidationIssue(params: { + schema: { + safeParse: ( + value: unknown, + ) => + | { success: true; data: unknown } + | { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } }; + }; config: unknown; expectedPath: string; + expectedMessage: string; }) { const res = params.schema.safeParse(params.config); expect(res.success).toBe(false); if (!res.success) { - const issues = - (res.error as { issues?: Array<{ path?: Array }> }).issues ?? []; - expect(issues[0]?.path?.join(".")).toBe(params.expectedPath); + const issue = res.error.issues[0]; + expect(issue?.path.join(".")).toBe(params.expectedPath); + expect(issue?.message).toContain(params.expectedMessage); } } -function expectSchemaConfigValueStrict(params: { - schema: { safeParse: (value: unknown) => { success: true; data: unknown } | { success: false } }; - config: unknown; - readValue: (config: unknown) => unknown; - expectedValue: unknown; -}) { - const res = params.schema.safeParse(params.config); - expect(res.success).toBe(true); - if (!res.success) { - throw new Error("expected provider schema config to be valid"); - } - expect(params.readValue(res.data)).toBe(params.expectedValue); -} - -const fastProviderSchemas = { - telegram: TelegramConfigSchema, - whatsapp: WhatsAppConfigSchema, - signal: SignalConfigSchema, - imessage: IMessageConfigSchema, -} as const; - describe("legacy config detection", () => { it.each([ { @@ -166,87 +136,80 @@ describe("legacy config detection", () => { it.each([ { name: "telegram", - allowFrom: ["123456789"], schema: TelegramConfigSchema, - expectedIssuePath: "allowFrom", + allowFrom: ["123456789"], + expectedMessage: 'channels.telegram.dmPolicy="open"', }, { name: "whatsapp", - allowFrom: ["+15555550123"], schema: WhatsAppConfigSchema, - expectedIssuePath: "allowFrom", + allowFrom: ["+15555550123"], + expectedMessage: 'channels.whatsapp.dmPolicy="open"', }, { name: "signal", - allowFrom: ["+15555550123"], schema: SignalConfigSchema, - expectedIssuePath: "allowFrom", + allowFrom: ["+15555550123"], + expectedMessage: 'channels.signal.dmPolicy="open"', }, { name: "imessage", - allowFrom: ["+15555550123"], schema: IMessageConfigSchema, - expectedIssuePath: "allowFrom", + allowFrom: ["+15555550123"], + expectedMessage: 'channels.imessage.dmPolicy="open"', }, ] as const)( 'enforces dmPolicy="open" allowFrom wildcard for $name', - ({ name, allowFrom, expectedIssuePath, schema }) => { - const config = { dmPolicy: "open", allowFrom }; - if (schema) { - expectProviderSchemaValidationIssuePath({ - schema, - config, - expectedPath: expectedIssuePath, - }); - return; - } - expectProviderValidationIssuePath({ - provider: name, - config, - expectedPath: expectedIssuePath, - }); - }, - 180_000, - ); - - it.each(["telegram", "whatsapp", "signal"] as const)( - 'accepts dmPolicy="open" with wildcard for %s', - (provider) => { - expectSchemaConfigValueStrict({ - schema: fastProviderSchemas[provider], - config: { dmPolicy: "open", allowFrom: ["*"] }, - readValue: (config) => (config as { dmPolicy?: string }).dmPolicy, - expectedValue: "open", + ({ schema, allowFrom, expectedMessage }) => { + expectSchemaValidationIssue({ + schema, + config: { dmPolicy: "open", allowFrom }, + expectedPath: "allowFrom", + expectedMessage, }); }, ); - it.each(["telegram", "whatsapp", "signal"] as const)( - "defaults dm/group policy for configured provider %s", - (provider) => { - expectSchemaConfigValueStrict({ - schema: fastProviderSchemas[provider], - config: {}, - readValue: (config) => (config as { dmPolicy?: string }).dmPolicy, - expectedValue: "pairing", - }); - expectSchemaConfigValueStrict({ - schema: fastProviderSchemas[provider], - config: {}, - readValue: (config) => (config as { groupPolicy?: string }).groupPolicy, - expectedValue: "allowlist", - }); - }, - ); + it.each([ + { name: "telegram", schema: TelegramConfigSchema }, + { name: "whatsapp", schema: WhatsAppConfigSchema }, + { name: "signal", schema: SignalConfigSchema }, + ] as const)('accepts dmPolicy="open" with wildcard for $name', ({ schema }) => { + expectSchemaConfigValue({ + schema, + config: { dmPolicy: "open", allowFrom: ["*"] }, + readValue: (config) => (config as { dmPolicy?: string }).dmPolicy, + expectedValue: "open", + }); + }); + + it.each([ + { name: "telegram", schema: TelegramConfigSchema }, + { name: "whatsapp", schema: WhatsAppConfigSchema }, + { name: "signal", schema: SignalConfigSchema }, + ] as const)("defaults dm/group policy for configured provider $name", ({ schema }) => { + expectSchemaConfigValue({ + schema, + config: {}, + readValue: (config) => (config as { dmPolicy?: string }).dmPolicy, + expectedValue: "pairing", + }); + expectSchemaConfigValue({ + schema, + config: {}, + readValue: (config) => (config as { groupPolicy?: string }).groupPolicy, + expectedValue: "allowlist", + }); + }); it("accepts historyLimit overrides per provider and account", async () => { - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: WhatsAppConfigSchema, config: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } }, readValue: (config) => (config as { historyLimit?: number }).historyLimit, expectedValue: 9, }); - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: WhatsAppConfigSchema, config: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } }, readValue: (config) => @@ -254,13 +217,13 @@ describe("legacy config detection", () => { ?.historyLimit, expectedValue: 4, }); - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: TelegramConfigSchema, config: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } }, readValue: (config) => (config as { historyLimit?: number }).historyLimit, expectedValue: 8, }); - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: TelegramConfigSchema, config: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } }, readValue: (config) => @@ -280,13 +243,13 @@ describe("legacy config detection", () => { (config as { accounts?: { ops?: { historyLimit?: number } } }).accounts?.ops?.historyLimit, expectedValue: 2, }); - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: SignalConfigSchema, config: { historyLimit: 6 }, readValue: (config) => (config as { historyLimit?: number }).historyLimit, expectedValue: 6, }); - expectSchemaConfigValueStrict({ + expectSchemaConfigValue({ schema: IMessageConfigSchema, config: { historyLimit: 5 }, readValue: (config) => (config as { historyLimit?: number }).historyLimit, diff --git a/src/config/config.legacy-config-provider-shapes.test.ts b/src/config/config.legacy-config-provider-shapes.test.ts index 980a19f89e1..b326bdc690d 100644 --- a/src/config/config.legacy-config-provider-shapes.test.ts +++ b/src/config/config.legacy-config-provider-shapes.test.ts @@ -1,39 +1,47 @@ import { describe, expect, it } from "vitest"; -import { readConfigFileSnapshot } from "./config.js"; -import { withTempHome, writeOpenClawConfig } from "./test-helpers.js"; -import { validateConfigObject } from "./validation.js"; +import { normalizeLegacyTalkConfig } from "../commands/doctor/shared/legacy-talk-config-normalizer.js"; +import type { OpenClawConfig } from "./types.js"; +import { OpenClawSchema } from "./zod-schema.js"; describe("legacy provider-shaped config snapshots", () => { - it("accepts a string map of voice aliases while still flagging legacy talk config", async () => { - await withTempHome(async (home) => { - await writeOpenClawConfig(home, { - talk: { - voiceAliases: { - Clawd: "VoiceAlias1234567890", - Roger: "CwhRBWXzGAHq8TQ4Fs17", - }, + it("accepts a string map of voice aliases while still flagging legacy talk config", () => { + const raw = { + talk: { + voiceAliases: { + Clawd: "VoiceAlias1234567890", + Roger: "CwhRBWXzGAHq8TQ4Fs17", }, - }); + }, + }; + const changes: string[] = []; + const migrated = normalizeLegacyTalkConfig(raw as unknown as OpenClawConfig, changes); - const snap = await readConfigFileSnapshot(); - - expect(snap.valid).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "talk")).toBe(true); - expect(snap.sourceConfig.talk?.providers?.elevenlabs?.voiceAliases).toEqual({ - Clawd: "VoiceAlias1234567890", - Roger: "CwhRBWXzGAHq8TQ4Fs17", - }); + expect(changes).toContain( + "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", + ); + const next = migrated as { + talk?: { + providers?: { + elevenlabs?: { + voiceAliases?: Record; + }; + }; + }; + }; + expect(next?.talk?.providers?.elevenlabs?.voiceAliases).toEqual({ + Clawd: "VoiceAlias1234567890", + Roger: "CwhRBWXzGAHq8TQ4Fs17", }); }); it("rejects non-string voice alias values", () => { - const res = validateConfigObject({ + const res = OpenClawSchema.safeParse({ talk: { voiceAliases: { Clawd: 123, }, }, }); - expect(res.ok).toBe(false); + expect(res.success).toBe(false); }); }); diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index acd698e2139..8a993801a88 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; +import { + applyAgentDefaults, + applyContextPruningDefaults, + applyMessageDefaults, +} from "./defaults.js"; const mocks = vi.hoisted(() => ({ applyProviderConfigDefaultsForConfig: vi.fn(), @@ -13,15 +18,8 @@ vi.mock("./provider-policy.js", () => ({ _params.providerConfig, })); -let applyContextPruningDefaults: typeof import("./defaults.js").applyContextPruningDefaults; -let applyAgentDefaults: typeof import("./defaults.js").applyAgentDefaults; -let applyMessageDefaults: typeof import("./defaults.js").applyMessageDefaults; - describe("config defaults", () => { - beforeEach(async () => { - vi.resetModules(); - ({ applyAgentDefaults, applyContextPruningDefaults, applyMessageDefaults } = - await import("./defaults.js")); + beforeEach(() => { mocks.applyProviderConfigDefaultsForConfig.mockReset(); }); diff --git a/src/config/mcp-config.test.ts b/src/config/mcp-config.test.ts index b1795e0d8db..c0fb5bf0778 100644 --- a/src/config/mcp-config.test.ts +++ b/src/config/mcp-config.test.ts @@ -1,15 +1,85 @@ import fs from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; import { listConfiguredMcpServers, setConfiguredMcpServer, unsetConfiguredMcpServer, } from "./mcp-config.js"; -import { withTempHomeConfig } from "./test-helpers.js"; + +function validationOk(raw: unknown) { + return { ok: true as const, config: raw, warnings: [] }; +} + +const mockReadSourceConfigSnapshot = vi.hoisted(() => async () => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const configPath = path.join(process.env.OPENCLAW_STATE_DIR ?? "", "openclaw.json"); + try { + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw); + return { + valid: true, + path: configPath, + sourceConfig: parsed, + resolved: parsed, + hash: "test-hash", + }; + } catch { + return { + valid: false, + path: configPath, + }; + } +}); + +const mockReplaceConfigFile = vi.hoisted(() => async ({ nextConfig }: { nextConfig: unknown }) => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const configPath = path.join(process.env.OPENCLAW_STATE_DIR ?? "", "openclaw.json"); + await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2), "utf-8"); +}); + +vi.mock("./io.js", () => ({ + readSourceConfigSnapshot: mockReadSourceConfigSnapshot, +})); + +vi.mock("./mutate.js", () => ({ + replaceConfigFile: mockReplaceConfigFile, +})); + +vi.mock("./validation.js", () => ({ + validateConfigObjectWithPlugins: validationOk, + validateConfigObjectRawWithPlugins: validationOk, +})); + +async function withMcpConfigHome( + config: unknown, + fn: (params: { configPath: string }) => Promise, +) { + return await withTempHome( + async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + return await fn({ configPath }); + }, + { + prefix: "openclaw-mcp-config-", + skipSessionCleanup: true, + env: { + OPENCLAW_CONFIG_PATH: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + }, + ); +} describe("config mcp config", () => { it("writes and removes top-level mcp servers", async () => { - await withTempHomeConfig({}, async () => { + await withMcpConfigHome({}, async () => { const setResult = await setConfiguredMcpServer({ name: "context7", server: { @@ -42,7 +112,7 @@ describe("config mcp config", () => { }); it("fails closed when the config file is invalid", async () => { - await withTempHomeConfig({}, async ({ configPath }) => { + await withMcpConfigHome({}, async ({ configPath }) => { await fs.writeFile(configPath, "{", "utf-8"); const loaded = await listConfiguredMcpServers(); @@ -55,7 +125,7 @@ describe("config mcp config", () => { }); it("accepts SSE MCP configs with headers at the config layer", async () => { - await withTempHomeConfig({}, async () => { + await withMcpConfigHome({}, async () => { const setResult = await setConfiguredMcpServer({ name: "remote", server: { diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index 185b843f44e..f8d74a690e8 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -1,58 +1,102 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { - ConfigMutationConflictError, - mutateConfigFile, - readSourceConfigSnapshot, - replaceConfigFile, -} from "./config.js"; -import { withTempHome } from "./home-env.test-harness.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; +import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; + +const ioMocks = vi.hoisted(() => ({ + readConfigFileSnapshotForWrite: vi.fn(), + resolveConfigSnapshotHash: vi.fn(), + writeConfigFile: vi.fn(), +})); + +vi.mock("./io.js", () => ioMocks); + +function createSnapshot(params: { + hash: string; + path?: string; + sourceConfig: OpenClawConfig; + runtimeConfig?: OpenClawConfig; +}): ConfigFileSnapshot { + const runtimeConfig = (params.runtimeConfig ?? + params.sourceConfig) as ConfigFileSnapshot["config"]; + const sourceConfig = params.sourceConfig as ConfigFileSnapshot["sourceConfig"]; + return { + path: params.path ?? "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: params.sourceConfig, + sourceConfig, + resolved: sourceConfig, + valid: true, + runtimeConfig, + config: runtimeConfig, + hash: params.hash, + issues: [], + warnings: [], + legacyIssues: [], + }; +} describe("config mutate helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + ioMocks.resolveConfigSnapshotHash.mockImplementation( + (snapshot: { hash?: string }) => snapshot.hash ?? null, + ); + }); + it("mutates source config with optimistic hash protection", async () => { - await withTempHome("openclaw-config-mutate-source-", async (home) => { - const configPath = path.join(home, ".openclaw", "openclaw.json"); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); - - const snapshot = await readSourceConfigSnapshot(); - await mutateConfigFile({ - baseHash: snapshot.hash, - base: "source", - mutate(draft) { - draft.gateway = { - ...draft.gateway, - auth: { mode: "token" }, - }; - }, - }); - - const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as { - gateway?: { port?: number; auth?: unknown }; - }; - expect(persisted.gateway).toEqual({ - port: 18789, - auth: { mode: "token" }, - }); + const snapshot = createSnapshot({ + hash: "source-hash", + sourceConfig: { gateway: { port: 18789 } }, + runtimeConfig: { gateway: { port: 19001 } }, }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + const result = await mutateConfigFile({ + baseHash: snapshot.hash, + base: "source", + mutate(draft) { + draft.gateway = { + ...draft.gateway, + auth: { mode: "token" }, + }; + }, + }); + + expect(result.previousHash).toBe("source-hash"); + expect(result.nextConfig.gateway).toEqual({ + port: 18789, + auth: { mode: "token" }, + }); + expect(ioMocks.writeConfigFile).toHaveBeenCalledWith( + { + gateway: { + port: 18789, + auth: { mode: "token" }, + }, + }, + { expectedConfigPath: snapshot.path }, + ); }); it("rejects stale replace attempts when the base hash changed", async () => { - await withTempHome("openclaw-config-replace-conflict-", async (home) => { - const configPath = path.join(home, ".openclaw", "openclaw.json"); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); - - const snapshot = await readSourceConfigSnapshot(); - await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 19001 } }, null, 2)}\n`); - - await expect( - replaceConfigFile({ - baseHash: snapshot.hash, - nextConfig: { gateway: { port: 19002 } }, - }), - ).rejects.toBeInstanceOf(ConfigMutationConflictError); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: createSnapshot({ + hash: "new-hash", + sourceConfig: { gateway: { port: 19001 } }, + }), + writeOptions: {}, }); + + await expect( + replaceConfigFile({ + baseHash: "old-hash", + nextConfig: { gateway: { port: 19002 } }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); }); }); diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index bf0ca384e4d..a54ee764ed7 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -256,6 +256,45 @@ function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean { ); } +function listContainsNormalized(value: unknown, expected: string): boolean { + return ( + Array.isArray(value) && + value.some((entry) => normalizeOptionalLowercaseString(entry) === expected) + ); +} + +function toolPolicyReferencesBrowser(value: unknown): boolean { + return ( + isRecord(value) && + (listContainsNormalized(value.allow, "browser") || + listContainsNormalized(value.alsoAllow, "browser")) + ); +} + +function hasBrowserToolReference(cfg: OpenClawConfig): boolean { + if (toolPolicyReferencesBrowser(cfg.tools)) { + return true; + } + const agentList = cfg.agents?.list; + return Array.isArray(agentList) + ? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools)) + : false; +} + +function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { + const entries = cfg.plugins?.entries; + if (isRecord(cfg.browser) || isRecord(cfg.acp) || hasBrowserToolReference(cfg)) { + return true; + } + if (isRecord(entries?.browser) || isRecord(entries?.acpx) || isRecord(entries?.xai)) { + return true; + } + if (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) { + return true; + } + return hasConfiguredPluginConfigEntry(cfg); +} + function hasPluginEntries(cfg: OpenClawConfig): boolean { const entries = cfg.plugins?.entries; return !!entries && typeof entries === "object" && Object.keys(entries).length > 0; @@ -321,6 +360,9 @@ export function configMayNeedPluginAutoEnable( if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) { return true; } + if (!hasSetupAutoEnableRelevantConfig(cfg)) { + return false; + } return ( resolvePluginSetupAutoEnableReasons({ config: cfg, @@ -428,15 +470,17 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const entry of resolvePluginSetupAutoEnableReasons({ - config: params.config, - env: params.env, - })) { - changes.push({ - pluginId: entry.pluginId, - kind: "setup-auto-enable", - reason: entry.reason, - }); + if (hasSetupAutoEnableRelevantConfig(params.config)) { + for (const entry of resolvePluginSetupAutoEnableReasons({ + config: params.config, + env: params.env, + })) { + changes.push({ + pluginId: entry.pluginId, + kind: "setup-auto-enable", + reason: entry.reason, + }); + } } return changes; diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index 134e99d5f39..b7480aa1880 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -126,14 +126,16 @@ export function deriveGroupSessionPatch(params: { const subject = params.ctx.GroupSubject?.trim(); const space = params.ctx.GroupSpace?.trim(); const explicitChannel = params.ctx.GroupChannel?.trim(); - const normalizedChannel = normalizeChannelId(channel); + const subjectLooksChannel = Boolean(subject?.startsWith("#")); + const normalizedChannel = + subjectLooksChannel && resolution.chatType !== "channel" ? normalizeChannelId(channel) : null; const isChannelProvider = Boolean( normalizedChannel && getChannelPlugin(normalizedChannel)?.capabilities.chatTypes.includes("channel"), ); const nextGroupChannel = explicitChannel ?? - ((resolution.chatType === "channel" || isChannelProvider) && subject && subject.startsWith("#") + (subjectLooksChannel && subject && (resolution.chatType === "channel" || isChannelProvider) ? subject : undefined); const nextSubject = nextGroupChannel ? undefined : subject; diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 7e53e0158a8..84109795117 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -28,7 +28,7 @@ export const DEFAULT_RESET_AT_HOUR = 4; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; export function isThreadSessionKey(sessionKey?: string | null): boolean { - return Boolean(resolveSessionThreadInfo(sessionKey).threadId); + return Boolean(resolveSessionThreadInfo(sessionKey, { bundledFallback: false }).threadId); } export function resolveSessionResetType(params: { diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts index 17c886eb65a..e668db26503 100644 --- a/src/config/sessions/session-file.ts +++ b/src/config/sessions/session-file.ts @@ -1,4 +1,5 @@ import { resolveSessionFilePath } from "./paths.js"; +import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js"; import { updateSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; @@ -12,6 +13,7 @@ export async function resolveAndPersistSessionFile(params: { sessionsDir?: string; fallbackSessionFile?: string; activeSessionKey?: string; + maintenanceConfig?: ResolvedSessionMaintenanceConfig; }): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> { const { sessionId, sessionKey, sessionStore, storePath } = params; const baseEntry = params.sessionEntry ?? @@ -41,7 +43,12 @@ export async function resolveAndPersistSessionFile(params: { ...persistedEntry, }; }, - params.activeSessionKey ? { activeSessionKey: params.activeSessionKey } : undefined, + params.activeSessionKey || params.maintenanceConfig + ? { + ...(params.activeSessionKey ? { activeSessionKey: params.activeSessionKey } : {}), + ...(params.maintenanceConfig ? { maintenanceConfig: params.maintenanceConfig } : {}), + } + : undefined, ); return { sessionFile, sessionEntry: persistedEntry }; } diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 2c7c0846aae..37ec6147278 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -133,13 +133,9 @@ function resolveHighWaterBytes( * Resolve maintenance settings from openclaw.json (`session.maintenance`). * Falls back to built-in defaults when config is missing or unset. */ -export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { - let maintenance: SessionMaintenanceConfig | undefined; - try { - maintenance = loadConfig().session?.maintenance; - } catch { - // Config may not be available (e.g. in tests). Use defaults. - } +export function resolveMaintenanceConfigFromInput( + maintenance?: SessionMaintenanceConfig, +): ResolvedSessionMaintenanceConfig { const pruneAfterMs = resolvePruneAfterMs(maintenance); const maxDiskBytes = resolveMaxDiskBytes(maintenance); return { @@ -153,6 +149,16 @@ export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { }; } +export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { + let maintenance: SessionMaintenanceConfig | undefined; + try { + maintenance = loadConfig().session?.maintenance; + } catch { + // Config may not be available (e.g. in tests). Use defaults. + } + return resolveMaintenanceConfigFromInput(maintenance); +} + /** * Remove entries whose `updatedAt` is older than the configured threshold. * Entries without `updatedAt` are kept (cannot determine staleness). diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 6b4fbc3606e..2637a04cba5 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -191,6 +191,8 @@ type SaveSessionStoreOptions = { onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise; /** Optional overrides used by maintenance commands. */ maintenanceOverride?: Partial; + /** Fully resolved maintenance settings when the caller already has config loaded. */ + maintenanceConfig?: ResolvedSessionMaintenanceConfig; }; function updateSessionStoreWriteCaches(params: { @@ -280,7 +282,9 @@ async function saveSessionStoreUnlocked( if (!opts?.skipMaintenance) { // Resolve maintenance config once (avoids repeated loadConfig() calls). - const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride }; + const maintenance = opts?.maintenanceConfig + ? { ...opts.maintenanceConfig, ...opts?.maintenanceOverride } + : { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride }; const shouldWarnOnly = maintenance.mode === "warn"; const beforeCount = Object.keys(store).length; diff --git a/src/config/validation.channel-metadata.test.ts b/src/config/validation.channel-metadata.test.ts index 2551ffcb412..5adebcf8154 100644 --- a/src/config/validation.channel-metadata.test.ts +++ b/src/config/validation.channel-metadata.test.ts @@ -1,48 +1,25 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, } from "./validation.js"; -type MockPluginRegistry = { - diagnostics: Array>; - plugins: Array>; -}; - -function createEmptyPluginRegistry(): MockPluginRegistry { - return { diagnostics: [], plugins: [] }; -} - const mockLoadPluginManifestRegistry = vi.hoisted(() => - vi.fn<(...args: unknown[]) => MockPluginRegistry>(() => createEmptyPluginRegistry()), + vi.fn( + (): PluginManifestRegistry => ({ + diagnostics: [], + plugins: [], + }), + ), ); -vi.mock("../plugins/manifest-registry.js", () => { +function createTelegramSchemaRegistry(): PluginManifestRegistry { return { - loadPluginManifestRegistry: (...args: unknown[]) => mockLoadPluginManifestRegistry(...args), - resolveManifestContractPluginIds: () => [], - }; -}); - -vi.mock("../plugins/doctor-contract-registry.js", () => { - return { - collectRelevantDoctorPluginIds: () => [], - listPluginDoctorLegacyConfigRules: () => [], - }; -}); - -afterEach(() => { - mockLoadPluginManifestRegistry.mockReset(); - mockLoadPluginManifestRegistry.mockImplementation(createEmptyPluginRegistry); -}); - -function setupTelegramSchemaWithDefault() { - mockLoadPluginManifestRegistry.mockReturnValue({ diagnostics: [], plugins: [ - { + createPluginManifestRecord({ id: "telegram", - origin: "bundled", channels: ["telegram"], channelCatalogMeta: { id: "telegram", @@ -69,21 +46,17 @@ function setupTelegramSchemaWithDefault() { uiHints: {}, }, }, - }, + }), ], - }); + }; } -function setupPluginSchemaWithRequiredDefault() { - mockLoadPluginManifestRegistry.mockReturnValue({ +function createPluginConfigSchemaRegistry(): PluginManifestRegistry { + return { diagnostics: [], plugins: [ - { + createPluginManifestRecord({ id: "opik", - origin: "bundled", - channels: [], - providers: [], - kind: ["tool"], configSchema: { type: "object", properties: { @@ -95,9 +68,55 @@ function setupPluginSchemaWithRequiredDefault() { required: ["workspace"], additionalProperties: true, }, - }, + }), ], - }); + }; +} + +function createPluginManifestRecord( + overrides: Partial & Pick, +): PluginManifestRecord { + return { + channels: [], + cliBackends: [], + hooks: [], + manifestPath: `/tmp/${overrides.id}/openclaw.plugin.json`, + origin: "bundled", + providers: [], + rootDir: `/tmp/${overrides.id}`, + skills: [], + source: `/tmp/${overrides.id}/index.js`, + ...overrides, + }; +} + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: () => mockLoadPluginManifestRegistry(), + resolveManifestContractPluginIds: () => [], +})); + +vi.mock("../plugins/doctor-contract-registry.js", () => ({ + collectRelevantDoctorPluginIds: () => [], + listPluginDoctorLegacyConfigRules: () => [], + applyPluginDoctorCompatibilityMigrations: () => ({ next: null, changes: [] }), +})); + +vi.mock("../channels/plugins/legacy-config.js", () => ({ + collectChannelLegacyConfigRules: () => [], +})); + +vi.mock("./zod-schema.js", () => ({ + OpenClawSchema: { + safeParse: (raw: unknown) => ({ success: true, data: raw }), + }, +})); + +function setupTelegramSchemaWithDefault() { + mockLoadPluginManifestRegistry.mockReturnValue(createTelegramSchemaRegistry()); +} + +function setupPluginSchemaWithRequiredDefault() { + mockLoadPluginManifestRegistry.mockReturnValue(createPluginConfigSchemaRegistry()); } describe("validateConfigObjectWithPlugins channel metadata (applyDefaults: true)", () => { diff --git a/src/config/validation.policy.test.ts b/src/config/validation.policy.test.ts index fd443989ce7..ada44cdd2e3 100644 --- a/src/config/validation.policy.test.ts +++ b/src/config/validation.policy.test.ts @@ -1,35 +1,46 @@ import { describe, expect, it, vi } from "vitest"; import { validateConfigObjectRaw } from "./validation.js"; -vi.mock("../secrets/unsupported-surface-policy.js", () => ({ - collectUnsupportedSecretRefConfigCandidates: (raw: unknown) => { - const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === "object" && !Array.isArray(value); - const candidates: Array<{ path: string; value: unknown }> = []; - if (!isRecord(raw)) { - return candidates; - } - - const hooks = isRecord(raw.hooks) ? raw.hooks : null; - if (hooks) { - candidates.push({ path: "hooks.token", value: hooks.token }); - } - - const channels = isRecord(raw.channels) ? raw.channels : null; - const discord = channels && isRecord(channels.discord) ? channels.discord : null; - const threadBindings = - discord && isRecord(discord.threadBindings) ? discord.threadBindings : null; - if (threadBindings) { - candidates.push({ - path: "channels.discord.threadBindings.webhookToken", - value: threadBindings.webhookToken, - }); - } - - return candidates; - }, +vi.mock("../channels/plugins/legacy-config.js", () => ({ + collectChannelLegacyConfigRules: () => [], })); +vi.mock("../plugins/doctor-contract-registry.js", () => ({ + collectRelevantDoctorPluginIds: () => [], + listPluginDoctorLegacyConfigRules: () => [], +})); + +vi.mock("../secrets/unsupported-surface-policy.js", async () => { + const { isRecord } = await import("../utils.js"); + + return { + collectUnsupportedSecretRefConfigCandidates: (raw: unknown) => { + if (!isRecord(raw)) { + return []; + } + const candidates: Array<{ path: string; value: unknown }> = []; + + const hooks = isRecord(raw.hooks) ? raw.hooks : null; + if (hooks) { + candidates.push({ path: "hooks.token", value: hooks.token }); + } + + const channels = isRecord(raw.channels) ? raw.channels : null; + const discord = channels && isRecord(channels.discord) ? channels.discord : null; + const threadBindings = + discord && isRecord(discord.threadBindings) ? discord.threadBindings : null; + if (threadBindings) { + candidates.push({ + path: "channels.discord.threadBindings.webhookToken", + value: threadBindings.webhookToken, + }); + } + + return candidates; + }, + }; +}); + describe("config validation SecretRef policy guards", () => { it("surfaces a policy error for hooks.token SecretRef objects", () => { const result = validateConfigObjectRaw({ diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 5b9935c4dee..2d75a346bf5 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -18,6 +18,16 @@ const mocks = vi.hoisted(() => ({ loadPluginManifestRegistry: vi.fn<() => MockManifestRegistry>(() => createEmptyMockManifestRegistry(), ), + withBundledPluginAllowlistCompat: vi.fn( + ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => + ({ + ...config, + plugins: { + ...config?.plugins, + allow: Array.from(new Set([...(config?.plugins?.allow ?? []), ...pluginIds])), + }, + }) as OpenClawConfig, + ), withBundledPluginEnablementCompat: vi.fn(({ config }) => config), withBundledPluginVitestCompat: vi.fn(({ config }) => config), })); @@ -30,14 +40,11 @@ vi.mock("./manifest-registry.js", () => ({ loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, })); -vi.mock("./bundled-compat.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat, - withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat, - }; -}); +vi.mock("./bundled-compat.js", () => ({ + withBundledPluginAllowlistCompat: mocks.withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat, + withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat, +})); let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders; @@ -150,6 +157,17 @@ describe("resolvePluginCapabilityProviders", () => { mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined); mocks.loadPluginManifestRegistry.mockReset(); mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry()); + mocks.withBundledPluginAllowlistCompat.mockClear(); + mocks.withBundledPluginAllowlistCompat.mockImplementation( + ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => + ({ + ...config, + plugins: { + ...config?.plugins, + allow: Array.from(new Set([...(config?.plugins?.allow ?? []), ...pluginIds])), + }, + }) as OpenClawConfig, + ); mocks.withBundledPluginEnablementCompat.mockReset(); mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config); mocks.withBundledPluginVitestCompat.mockReset(); diff --git a/src/plugins/config-contracts.test.ts b/src/plugins/config-contracts.test.ts index e9190df0e31..87961ebecf6 100644 --- a/src/plugins/config-contracts.test.ts +++ b/src/plugins/config-contracts.test.ts @@ -79,4 +79,14 @@ describe("resolvePluginConfigContractsById", () => { ).toEqual(new Map()); expect(mocks.findBundledPluginMetadataById).not.toHaveBeenCalled(); }); + + it("can skip bundled metadata fallback for registry-scoped callers", () => { + expect( + resolvePluginConfigContractsById({ + pluginIds: ["missing"], + fallbackToBundledMetadata: false, + }), + ).toEqual(new Map()); + expect(mocks.findBundledPluginMetadataById).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 987860e5718..b97f89b46e8 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -102,6 +102,7 @@ export function resolvePluginConfigContractsById(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; cache?: boolean; + fallbackToBundledMetadata?: boolean; pluginIds: readonly string[]; }): ReadonlyMap { const matches = new Map(); @@ -133,18 +134,20 @@ export function resolvePluginConfigContractsById(params: { }); } - for (const pluginId of pluginIds) { - if (matches.has(pluginId) || resolvedPluginIds.has(pluginId)) { - continue; + if (params.fallbackToBundledMetadata ?? true) { + for (const pluginId of pluginIds) { + if (matches.has(pluginId) || resolvedPluginIds.has(pluginId)) { + continue; + } + const bundled = findBundledPluginMetadataById(pluginId); + if (!bundled?.manifest.configContracts) { + continue; + } + matches.set(pluginId, { + origin: "bundled", + configContracts: bundled.manifest.configContracts, + }); } - const bundled = findBundledPluginMetadataById(pluginId); - if (!bundled?.manifest.configContracts) { - continue; - } - matches.set(pluginId, { - origin: "bundled", - configContracts: bundled.manifest.configContracts, - }); } return matches; diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 8aec2d28e57..b631b703ea0 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -7,7 +7,7 @@ import { asNullableRecord } from "../shared/record-coerce.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import { resolvePluginCacheInputs } from "./roots.js"; +import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js"; const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); @@ -35,8 +35,13 @@ type PluginDoctorContractEntry = { normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer; }; +type PluginManifestRegistryRecord = ReturnType< + typeof loadPluginManifestRegistry +>["plugins"][number]; + const jitiLoaders: PluginJitiLoaderCache = new Map(); const doctorContractCache = new Map(); +const doctorContractRecordCache = new Map>(); function getJiti(modulePath: string) { return getCachedPluginJitiLoader({ @@ -51,15 +56,31 @@ function buildDoctorContractCacheKey(params: { env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): string { + return JSON.stringify({ + ...resolveDoctorContractBaseCachePayload(params), + pluginIds: [...(params.pluginIds ?? [])].toSorted(), + }); +} + +function buildDoctorContractBaseCacheKey(params: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + return JSON.stringify(resolveDoctorContractBaseCachePayload(params)); +} + +function resolveDoctorContractBaseCachePayload(params: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): { + roots: PluginSourceRoots; + loadPaths: string[]; +} { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, env: params.env, }); - return JSON.stringify({ - roots, - loadPaths, - pluginIds: [...(params.pluginIds ?? [])].toSorted(), - }); + return { roots, loadPaths }; } function resolveContractApiPath(rootDir: string): string | null { @@ -140,12 +161,70 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] { return [...ids].toSorted(); } +function getDoctorContractRecordCache( + baseCacheKey: string, +): Map { + let cache = doctorContractRecordCache.get(baseCacheKey); + if (!cache) { + cache = new Map(); + doctorContractRecordCache.set(baseCacheKey, cache); + } + return cache; +} + +function loadPluginDoctorContractEntry( + record: PluginManifestRegistryRecord, + baseCacheKey: string, +): PluginDoctorContractEntry | null { + const cache = getDoctorContractRecordCache(baseCacheKey); + const cached = cache.get(record.id); + if (cached !== undefined) { + return cached; + } + + const contractSource = resolveContractApiPath(record.rootDir); + if (!contractSource) { + cache.set(record.id, null); + return null; + } + let mod: PluginDoctorContractModule; + try { + mod = getJiti(contractSource)(contractSource) as PluginDoctorContractModule; + } catch { + cache.set(record.id, null); + return null; + } + const rules = coerceLegacyConfigRules( + (mod as { default?: PluginDoctorContractModule }).default?.legacyConfigRules ?? + mod.legacyConfigRules, + ); + const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig( + mod.normalizeCompatibilityConfig ?? + (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, + ); + if (rules.length === 0 && !normalizeCompatibilityConfig) { + cache.set(record.id, null); + return null; + } + const entry = { + pluginId: record.id, + rules, + normalizeCompatibilityConfig, + }; + cache.set(record.id, entry); + return entry; +} + function resolvePluginDoctorContracts(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginDoctorContractEntry[] { const env = params?.env ?? process.env; + const baseCacheKey = buildDoctorContractBaseCacheKey({ + workspaceDir: params?.workspaceDir, + env, + }); const cacheKey = buildDoctorContractCacheKey({ workspaceDir: params?.workspaceDir, env, @@ -185,32 +264,10 @@ function resolvePluginDoctorContracts(params?: { ) { continue; } - const contractSource = resolveContractApiPath(record.rootDir); - if (!contractSource) { - continue; + const entry = loadPluginDoctorContractEntry(record, baseCacheKey); + if (entry) { + entries.push(entry); } - let mod: PluginDoctorContractModule; - try { - mod = getJiti(contractSource)(contractSource) as PluginDoctorContractModule; - } catch { - continue; - } - const rules = coerceLegacyConfigRules( - (mod as { default?: PluginDoctorContractModule }).default?.legacyConfigRules ?? - mod.legacyConfigRules, - ); - const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig( - mod.normalizeCompatibilityConfig ?? - (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, - ); - if (rules.length === 0 && !normalizeCompatibilityConfig) { - continue; - } - entries.push({ - pluginId: record.id, - rules, - normalizeCompatibilityConfig, - }); } doctorContractCache.set(cacheKey, entries); @@ -219,6 +276,7 @@ function resolvePluginDoctorContracts(params?: { export function clearPluginDoctorContractRegistryCache(): void { doctorContractCache.clear(); + doctorContractRecordCache.clear(); jitiLoaders.clear(); } diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index e9a1eed46b9..1208a7f9183 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; import { getRegistryJitiMocks, @@ -23,11 +23,13 @@ afterEach(() => { }); describe("setup-registry getJiti", () => { - beforeEach(async () => { - resetRegistryJitiMocks(); - vi.resetModules(); + beforeAll(async () => { ({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry, runPluginSetupConfigMigrations } = await import("./setup-registry.js")); + }); + + beforeEach(() => { + resetRegistryJitiMocks(); clearPluginSetupRegistryCache(); }); diff --git a/src/secrets/channel-contract-api.fast-path.test.ts b/src/secrets/channel-contract-api.fast-path.test.ts index aec847cf3f1..398a00bff64 100644 --- a/src/secrets/channel-contract-api.fast-path.test.ts +++ b/src/secrets/channel-contract-api.fast-path.test.ts @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({ @@ -6,14 +5,41 @@ const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({ throw new Error("manifest registry should stay off the explicit bundled channel fast path"); }), })); +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "bluebubbles" && artifactBasename === "secret-contract-api.js") { + return { + collectRuntimeConfigAssignments: () => undefined, + secretTargetRegistryEntries: [ + { + id: "channels.bluebubbles.accounts.*.password", + type: "channel", + path: "channels.bluebubbles.accounts.*.password", + }, + ], + }; + } + if (dirName === "whatsapp" && artifactBasename === "security-contract-api.js") { + return { + unsupportedSecretRefSurfacePatterns: ["channels.whatsapp.creds.json"], + collectUnsupportedSecretRefConfigCandidates: () => [], + }; + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); -vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadPluginManifestRegistry: loadPluginManifestRegistryMock, - }; -}); +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: loadPluginManifestRegistryMock, +})); + +vi.mock("../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); import { loadBundledChannelSecretContractApi, @@ -29,6 +55,10 @@ describe("channel contract api explicit fast path", () => { const api = loadBundledChannelSecretContractApi("bluebubbles"); expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "bluebubbles", + artifactBasename: "secret-contract-api.js", + }); expect(api?.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -46,27 +76,10 @@ describe("channel contract api explicit fast path", () => { expect.arrayContaining(["channels.whatsapp.creds.json"]), ); expect(api?.collectUnsupportedSecretRefConfigCandidates).toBeTypeOf("function"); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "whatsapp", + artifactBasename: "security-contract-api.js", + }); expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); - - it("keeps bundled channel ids aligned with their plugin directories", async () => { - const { loadPluginManifestRegistry } = await vi.importActual< - typeof import("../plugins/manifest-registry.js") - >("../plugins/manifest-registry.js"); - - const mismatches = loadPluginManifestRegistry({}) - .plugins.filter((record) => record.origin === "bundled") - .filter((record) => typeof record.rootDir === "string" && record.rootDir.trim().length > 0) - .flatMap((record) => - record.channels - .filter((channelId) => channelId !== basename(record.rootDir)) - .map((channelId) => ({ - id: record.id, - channelId, - dirName: basename(record.rootDir), - })), - ); - - expect(mismatches).toEqual([]); - }); }); diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 157c455289e..6a4125f3707 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -2,11 +2,7 @@ import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { isValidExecSecretRefId, isValidSecretProviderAlias } from "./ref-contract.js"; import { parseDotPath, toDotPath } from "./shared.js"; -import { - isKnownSecretTargetType, - resolvePlanTargetAgainstRegistry, - type ResolvedPlanTarget, -} from "./target-registry.js"; +import { resolvePlanTargetAgainstRegistry, type ResolvedPlanTarget } from "./target-registry.js"; export type SecretsPlanTargetType = string; @@ -81,7 +77,7 @@ export function resolveValidatedPlanTarget(candidate: { accountId?: string; authProfileProvider?: string; }): ResolvedPlanTarget | null { - if (!isKnownSecretTargetType(candidate.type)) { + if (typeof candidate.type !== "string" || !candidate.type.trim()) { return null; } const path = typeof candidate.path === "string" ? candidate.path.trim() : ""; @@ -127,7 +123,6 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { authProfileProvider: candidate.authProfileProvider, }); if ( - !isKnownSecretTargetType(candidate.type) || typeof candidate.path !== "string" || !candidate.path.trim() || (candidate.pathSegments !== undefined && !Array.isArray(candidate.pathSegments)) || diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index f28d0daa981..fbc88897808 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -52,6 +52,12 @@ describe("collectPluginConfigAssignments", () => { }, }, }, + { + id: "other", + origin: "config", + providers: [], + legacyPluginIds: [], + }, ], diagnostics: [], }); diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index 18c1ec034eb..d8d7022b760 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -47,6 +47,7 @@ export function collectPluginConfigAssignments(params: { workspaceDir, env: params.context.env, cache: true, + fallbackToBundledMetadata: false, pluginIds: Object.keys(entries), }).entries(), ].flatMap(([pluginId, metadata]) => { diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 7266274f09c..ae3050bd3e3 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -28,6 +28,7 @@ let compiledCoreOpenClawTargetState: { knownTargetIds: Set; openClawCompiledSecretTargets: CompiledTargetRegistryEntry[]; openClawTargetsById: Map; + targetsByType: Map; } | null = null; function buildTargetTypeIndex( @@ -100,6 +101,7 @@ function getCompiledCoreOpenClawTargetState() { knownTargetIds: new Set(openClawCompiledSecretTargets.map((entry) => entry.id)), openClawCompiledSecretTargets, openClawTargetsById: buildConfigTargetIdIndex(openClawCompiledSecretTargets), + targetsByType: buildTargetTypeIndex(openClawCompiledSecretTargets), }; return compiledCoreOpenClawTargetState; } @@ -241,7 +243,23 @@ export function resolvePlanTargetAgainstRegistry(candidate: { providerId?: string; accountId?: string; }): ResolvedPlanTarget | null { + const coreEntries = getCompiledCoreOpenClawTargetState().targetsByType.get(candidate.type); + if (coreEntries) { + return resolvePlanTargetAgainstEntries(candidate, coreEntries); + } const entries = getCompiledSecretTargetRegistryState().targetsByType.get(candidate.type); + return resolvePlanTargetAgainstEntries(candidate, entries); +} + +function resolvePlanTargetAgainstEntries( + candidate: { + type: string; + pathSegments: string[]; + providerId?: string; + accountId?: string; + }, + entries: CompiledTargetRegistryEntry[] | undefined, +): ResolvedPlanTarget | null { if (!entries || entries.length === 0) { return null; } diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 3e045902871..740c1299d8f 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -1,31 +1,14 @@ -import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { buildTalkTestProviderConfig, TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_ID, } from "../test-utils/talk-test-provider.js"; - -function runTargetRegistrySnippet(source: string): T { - const childEnv = { ...process.env }; - delete childEnv.NODE_OPTIONS; - delete childEnv.VITEST; - delete childEnv.VITEST_MODE; - delete childEnv.VITEST_POOL_ID; - delete childEnv.VITEST_WORKER_ID; - - const stdout = execFileSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "-e", source], - { - cwd: process.cwd(), - encoding: "utf8", - env: childEnv, - maxBuffer: 10 * 1024 * 1024, - }, - ); - return JSON.parse(stdout) as T; -} +import { + discoverConfigSecretTargetsByIds, + resolveConfigSecretTargetByPath, +} from "./target-registry.js"; describe("secret target registry", () => { it("supports filtered discovery by target ids", () => { @@ -33,19 +16,12 @@ describe("secret target registry", () => { ...buildTalkTestProviderConfig({ source: "env", provider: "default", id: "TALK_API_KEY" }), gateway: { remote: { - token: { source: "env", provider: "default", id: "REMOTE_TOKEN" }, + token: { source: "env" as const, provider: "default", id: "REMOTE_TOKEN" }, }, }, - }; + } satisfies OpenClawConfig; - const targets = runTargetRegistrySnippet< - Array<{ entry?: { id?: string }; providerId?: string; path?: string }> - >( - `import { discoverConfigSecretTargetsByIds } from "./src/secrets/target-registry.ts"; -const config = ${JSON.stringify(config)}; -const result = discoverConfigSecretTargetsByIds(config, new Set(["talk.providers.*.apiKey"])); -process.stdout.write(JSON.stringify(result));`, - ); + const targets = discoverConfigSecretTargetsByIds(config, new Set(["talk.providers.*.apiKey"])); expect(targets).toHaveLength(1); expect(targets[0]?.entry?.id).toBe("talk.providers.*.apiKey"); @@ -54,14 +30,7 @@ process.stdout.write(JSON.stringify(result));`, }); it("resolves config targets by exact path including sibling ref metadata", () => { - const target = runTargetRegistrySnippet<{ - entry?: { id?: string }; - refPathSegments?: string[]; - } | null>( - `import { resolveConfigSecretTargetByPath } from "./src/secrets/target-registry.ts"; -const result = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); -process.stdout.write(JSON.stringify(result));`, - ); + const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("channels.googlechat.serviceAccount"); @@ -69,11 +38,7 @@ process.stdout.write(JSON.stringify(result));`, }); it("returns null when no config target path matches", () => { - const target = runTargetRegistrySnippet( - `import { resolveConfigSecretTargetByPath } from "./src/secrets/target-registry.ts"; -const result = resolveConfigSecretTargetByPath(["gateway", "auth", "mode"]); -process.stdout.write(JSON.stringify(result));`, - ); + const target = resolveConfigSecretTargetByPath(["gateway", "auth", "mode"]); expect(target).toBeNull(); }); diff --git a/src/secrets/unsupported-surface-policy.test.ts b/src/secrets/unsupported-surface-policy.test.ts index b0b9db90c2b..2216ad07f27 100644 --- a/src/secrets/unsupported-surface-policy.test.ts +++ b/src/secrets/unsupported-surface-policy.test.ts @@ -1,4 +1,88 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const { loadBundledChannelSecurityContractApiMock, loadPluginManifestRegistryMock } = vi.hoisted( + () => ({ + loadBundledChannelSecurityContractApiMock: vi.fn((channelId: string) => { + if (channelId === "discord") { + return { + unsupportedSecretRefSurfacePatterns: [ + "channels.discord.threadBindings.webhookToken", + "channels.discord.accounts.*.threadBindings.webhookToken", + ], + collectUnsupportedSecretRefConfigCandidates: (raw: Record) => { + const discord = (raw.channels as Record | undefined)?.discord as + | Record + | undefined; + const candidates: Array<{ path: string; value: unknown }> = []; + const threadBindings = discord?.threadBindings as Record | undefined; + candidates.push({ + path: "channels.discord.threadBindings.webhookToken", + value: threadBindings?.webhookToken, + }); + const accounts = discord?.accounts as Record | undefined; + for (const [accountId, account] of Object.entries(accounts ?? {})) { + const accountThreadBindings = (account as Record).threadBindings as + | Record + | undefined; + candidates.push({ + path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`, + value: accountThreadBindings?.webhookToken, + }); + } + return candidates; + }, + }; + } + if (channelId === "whatsapp") { + return { + unsupportedSecretRefSurfacePatterns: [ + "channels.whatsapp.creds.json", + "channels.whatsapp.accounts.*.creds.json", + ], + collectUnsupportedSecretRefConfigCandidates: (raw: Record) => { + const whatsapp = (raw.channels as Record | undefined)?.whatsapp as + | Record + | undefined; + const candidates: Array<{ path: string; value: unknown }> = []; + const creds = whatsapp?.creds as Record | undefined; + candidates.push({ + path: "channels.whatsapp.creds.json", + value: creds?.json, + }); + const accounts = whatsapp?.accounts as Record | undefined; + for (const [accountId, account] of Object.entries(accounts ?? {})) { + const accountCreds = (account as Record).creds as + | Record + | undefined; + candidates.push({ + path: `channels.whatsapp.accounts.${accountId}.creds.json`, + value: accountCreds?.json, + }); + } + return candidates; + }, + }; + } + return undefined; + }), + loadPluginManifestRegistryMock: vi.fn(() => ({ + plugins: [ + { id: "discord", origin: "bundled", channels: ["discord"] }, + { id: "whatsapp", origin: "bundled", channels: ["whatsapp"] }, + ], + diagnostics: [], + })), + }), +); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: loadPluginManifestRegistryMock, +})); + +vi.mock("./channel-contract-api.js", () => ({ + loadBundledChannelSecurityContractApi: loadBundledChannelSecurityContractApiMock, +})); + import { collectUnsupportedSecretRefConfigCandidates, getUnsupportedSecretRefSurfacePatterns, diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index c61115fa5bd..0d959bed618 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -6,14 +6,26 @@ import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.j import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; import { withEnv } from "../../../src/test-utils/env.js"; -import * as tts from "../../../src/tts/tts.js"; +type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); + +let ttsRuntime: TtsRuntimeModule; +let ttsRuntimePromise: Promise | null = null; let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; let getApiKeyForModelMock: typeof import("../../../src/agents/model-auth.js").getApiKeyForModel; let requireApiKeyMock: typeof import("../../../src/agents/model-auth.js").requireApiKey; let resolveModelAsyncMock: typeof import("../../../src/agents/pi-embedded-runner/model.js").resolveModelAsync; let ensureCustomApiRegisteredMock: typeof import("../../../src/agents/custom-api-registry.js").ensureCustomApiRegistered; let prepareModelForSimpleCompletionMock: typeof import("../../../src/agents/simple-completion-transport.js").prepareModelForSimpleCompletion; +let resolveTtsConfig: TtsRuntimeModule["resolveTtsConfig"]; +let maybeApplyTtsToPayload: TtsRuntimeModule["maybeApplyTtsToPayload"]; +let getTtsProvider: TtsRuntimeModule["getTtsProvider"]; +let parseTtsDirectives: TtsRuntimeModule["_test"]["parseTtsDirectives"]; +let resolveModelOverridePolicy: TtsRuntimeModule["_test"]["resolveModelOverridePolicy"]; +let summarizeText: TtsRuntimeModule["_test"]["summarizeText"]; +let getResolvedSpeechProviderConfig: TtsRuntimeModule["_test"]["getResolvedSpeechProviderConfig"]; +let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; +let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; vi.mock("@mariozechner/pi-ai", async () => { const original = @@ -75,17 +87,6 @@ vi.mock("../../../src/agents/custom-api-registry.js", () => ({ ensureCustomApiRegistered: vi.fn(), })); -const { _test, resolveTtsConfig, maybeApplyTtsToPayload, getTtsProvider } = tts; - -const { - parseTtsDirectives, - resolveModelOverridePolicy, - summarizeText, - getResolvedSpeechProviderConfig, - formatTtsProviderError, - sanitizeTtsErrorForLog, -} = _test; - function asLegacyTtsConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } @@ -367,14 +368,27 @@ function buildTestElevenLabsSpeechProvider(): SpeechProviderPlugin { }; } -beforeEach(async () => { - ({ completeSimple } = await import("@mariozechner/pi-ai")); - ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = - await import("../../../src/agents/model-auth.js")); - ({ resolveModelAsync: resolveModelAsyncMock } = - await import("../../../src/agents/pi-embedded-runner/model.js")); - ({ ensureCustomApiRegistered: ensureCustomApiRegisteredMock } = - await import("../../../src/agents/custom-api-registry.js")); +async function loadTtsRuntime(): Promise { + ttsRuntimePromise ??= import("../../../src/tts/tts.js"); + return await ttsRuntimePromise; +} + +async function setupTtsRuntime() { + ttsRuntime = await loadTtsRuntime(); + resolveTtsConfig = ttsRuntime.resolveTtsConfig; + maybeApplyTtsToPayload = ttsRuntime.maybeApplyTtsToPayload; + getTtsProvider = ttsRuntime.getTtsProvider; + ({ + parseTtsDirectives, + resolveModelOverridePolicy, + summarizeText, + getResolvedSpeechProviderConfig, + formatTtsProviderError, + sanitizeTtsErrorForLog, + } = ttsRuntime._test); +} + +function setupTestSpeechProviderRegistry() { prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); const registry = createEmptyPluginRegistry(); registry.speechProviders = [ @@ -384,14 +398,49 @@ beforeEach(async () => { ]; const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); setActivePluginRegistry(registry, cacheKey); - vi.clearAllMocks(); +} + +async function setupSummarizationMocks() { + ({ completeSimple } = await import("@mariozechner/pi-ai")); + ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = + await import("../../../src/agents/model-auth.js")); + ({ resolveModelAsync: resolveModelAsyncMock } = + await import("../../../src/agents/pi-embedded-runner/model.js")); + ({ ensureCustomApiRegistered: ensureCustomApiRegisteredMock } = + await import("../../../src/agents/custom-api-registry.js")); vi.mocked(completeSimple).mockResolvedValue( mockAssistantMessage([{ type: "text", text: "Summary" }]), ); -}); + vi.mocked(getApiKeyForModelMock).mockResolvedValue({ + apiKey: "test-api-key", + source: "test", + mode: "api-key", + }); + vi.mocked(requireApiKeyMock).mockImplementation((auth: { apiKey?: string }) => auth.apiKey ?? ""); + vi.mocked(resolveModelAsyncMock).mockImplementation( + async (provider: string, modelId: string) => + createResolvedModel(provider, modelId) as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(ensureCustomApiRegisteredMock).mockReset(); +} + +async function setupTtsContractTest() { + await setupTtsRuntime(); + setupTestSpeechProviderRegistry(); + vi.clearAllMocks(); +} + +async function setupTtsSummarizationTest() { + await setupTtsContractTest(); + await setupSummarizationMocks(); +} export function describeTtsConfigContract() { describe("tts config contract", () => { + beforeEach(setupTtsContractTest); + describe("resolveEdgeOutputFormat", () => { const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, @@ -669,6 +718,8 @@ export function describeTtsConfigContract() { export function describeTtsSummarizationContract() { describe("tts summarization contract", () => { + beforeEach(setupTtsSummarizationTest); + const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, messages: { tts: {} }, @@ -780,6 +831,8 @@ export function describeTtsSummarizationContract() { export function describeTtsProviderRuntimeContract() { describe("tts provider runtime contract", () => { + beforeEach(setupTtsContractTest); + describe("provider error redaction", () => { it("redacts sensitive tokens in provider errors", () => { const result = formatTtsProviderError( @@ -838,7 +891,7 @@ export function describeTtsProviderRuntimeContract() { const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); setActivePluginRegistry(registry, cacheKey); - const result = await tts.synthesizeSpeech({ + const result = await ttsRuntime.synthesizeSpeech({ text: "hello fallback", cfg: { messages: { @@ -907,7 +960,7 @@ export function describeTtsProviderRuntimeContract() { const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); setActivePluginRegistry(registry, cacheKey); - const result = await tts.textToSpeechTelephony({ + const result = await ttsRuntime.textToSpeechTelephony({ text: "hello telephony fallback", cfg: { messages: { @@ -955,7 +1008,7 @@ export function describeTtsProviderRuntimeContract() { const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); setActivePluginRegistry(registry, cacheKey); - const result = await tts.textToSpeech({ + const result = await ttsRuntime.textToSpeech({ text: "hello", cfg: { messages: { @@ -985,7 +1038,7 @@ export function describeTtsProviderRuntimeContract() { expectedInstructions: string | undefined, ) { await withMockedSpeechFetch(async (fetchMock) => { - const result = await tts.textToSpeechTelephony({ + const result = await ttsRuntime.textToSpeechTelephony({ text: "Hello there, friendly caller.", cfg: createOpenAiTelephonyCfg(model), }); @@ -1018,6 +1071,8 @@ export function describeTtsProviderRuntimeContract() { export function describeTtsAutoApplyContract() { describe("tts auto-apply contract", () => { + beforeEach(setupTtsContractTest); + const baseCfg: OpenClawConfig = asLegacyOpenClawConfig({ agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, messages: {