perf: narrow plugin config test surfaces

This commit is contained in:
Peter Steinberger
2026-04-11 13:17:03 +01:00
parent bb0bfabec8
commit 8ddd9b8aac
38 changed files with 1241 additions and 814 deletions

View File

@@ -37,9 +37,15 @@ function expectPotentialConfiguredChannelCase(params: {
env: NodeJS.ProcessEnv;
expectedIds: string[];
expectedConfigured: boolean;
options?: Parameters<typeof listPotentialConfiguredChannelIds>[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,
},
},
});
});
});

View File

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

View File

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

View File

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

View File

@@ -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<ChannelId, BundledChannelEntryContract>;
setupEntriesById: Map<ChannelId, BundledChannelSetupEntryContract>;
sortedIds: readonly ChannelId[];
pluginsById: Map<ChannelId, ChannelPlugin>;
setupPluginsById: Map<ChannelId, ChannelPlugin>;
secretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
setupSecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
runtimeSettersById: Map<ChannelId, NonNullable<BundledChannelEntryContract["setChannelRuntime"]>>;
};
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<ChannelId>();
const setupPluginLoadInProgressIds = new Set<ChannelId>();
const entryLoadInProgressIds = new Set<ChannelId>();
const lazyEntriesById = new Map<ChannelId, GeneratedBundledChannelEntry | null>();
const lazyPluginsById = new Map<ChannelId, ChannelPlugin>();
const lazySetupPluginsById = new Map<ChannelId, ChannelPlugin>();
const lazySecretsById = new Map<ChannelId, ChannelPlugin["secrets"] | null>();
const lazySetupSecretsById = new Map<ChannelId, ChannelPlugin["secrets"] | null>();
function getBundledChannelState(): BundledChannelState {
if (cachedBundledChannelState) {
return cachedBundledChannelState;
}
if (bundledChannelStateLoadInProgress) {
return EMPTY_BUNDLED_CHANNEL_STATE;
}
bundledChannelStateLoadInProgress = true;
const entries = loadGeneratedBundledChannelEntries();
const entriesById = new Map<ChannelId, BundledChannelEntryContract>();
const setupEntriesById = new Map<ChannelId, BundledChannelSetupEntryContract>();
const runtimeSettersById = new Map<
ChannelId,
NonNullable<BundledChannelEntryContract["setChannelRuntime"]>
>();
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}`);
}

View File

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

View File

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

View File

@@ -5,6 +5,10 @@ import {
let builtinsRegisteredPromise: Promise<void> | null = null;
export function isStatefulTargetBuiltinDriverId(id: string): boolean {
return id.trim() === "acp";
}
export async function ensureStatefulTargetBuiltinsRegistered(): Promise<void> {
if (builtinsRegisteredPromise) {
await builtinsRegisteredPromise;

View File

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

View File

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

View File

@@ -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<typeof validateConfigObject>,
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<string | number> }> }).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",
);
});
});

View File

@@ -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<NonNullable<OpenClawConfig["agents"]>["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);
});
});

View File

@@ -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<string | number> }> }).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,

View File

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

View File

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

View File

@@ -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<T>(
config: unknown,
fn: (params: { configPath: string }) => Promise<T>,
) {
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: {

View File

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

View File

@@ -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<string, unknown>).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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -191,6 +191,8 @@ type SaveSessionStoreOptions = {
onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise<void>;
/** Optional overrides used by maintenance commands. */
maintenanceOverride?: Partial<ResolvedSessionMaintenanceConfig>;
/** 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;

View File

@@ -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<Record<string, unknown>>;
plugins: Array<Record<string, unknown>>;
};
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<PluginManifestRecord> & Pick<PluginManifestRecord, "id">,
): 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)", () => {

View File

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

View File

@@ -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<typeof import("./bundled-compat.js")>();
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();

View File

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

View File

@@ -102,6 +102,7 @@ export function resolvePluginConfigContractsById(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
fallbackToBundledMetadata?: boolean;
pluginIds: readonly string[];
}): ReadonlyMap<string, PluginConfigContractMetadata> {
const matches = new Map<string, PluginConfigContractMetadata>();
@@ -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;

View File

@@ -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<string, PluginDoctorContractEntry[]>();
const doctorContractRecordCache = new Map<string, Map<string, PluginDoctorContractEntry | null>>();
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<string, PluginDoctorContractEntry | null> {
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();
}

View File

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

View File

@@ -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<typeof import("../plugins/manifest-registry.js")>();
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([]);
});
});

View File

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

View File

@@ -52,6 +52,12 @@ describe("collectPluginConfigAssignments", () => {
},
},
},
{
id: "other",
origin: "config",
providers: [],
legacyPluginIds: [],
},
],
diagnostics: [],
});

View File

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

View File

@@ -28,6 +28,7 @@ let compiledCoreOpenClawTargetState: {
knownTargetIds: Set<string>;
openClawCompiledSecretTargets: CompiledTargetRegistryEntry[];
openClawTargetsById: Map<string, CompiledTargetRegistryEntry[]>;
targetsByType: Map<string, CompiledTargetRegistryEntry[]>;
} | 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;
}

View File

@@ -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<T>(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<unknown>(
`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();
});

View File

@@ -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<string, unknown>) => {
const discord = (raw.channels as Record<string, unknown> | undefined)?.discord as
| Record<string, unknown>
| undefined;
const candidates: Array<{ path: string; value: unknown }> = [];
const threadBindings = discord?.threadBindings as Record<string, unknown> | undefined;
candidates.push({
path: "channels.discord.threadBindings.webhookToken",
value: threadBindings?.webhookToken,
});
const accounts = discord?.accounts as Record<string, unknown> | undefined;
for (const [accountId, account] of Object.entries(accounts ?? {})) {
const accountThreadBindings = (account as Record<string, unknown>).threadBindings as
| Record<string, unknown>
| 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<string, unknown>) => {
const whatsapp = (raw.channels as Record<string, unknown> | undefined)?.whatsapp as
| Record<string, unknown>
| undefined;
const candidates: Array<{ path: string; value: unknown }> = [];
const creds = whatsapp?.creds as Record<string, unknown> | undefined;
candidates.push({
path: "channels.whatsapp.creds.json",
value: creds?.json,
});
const accounts = whatsapp?.accounts as Record<string, unknown> | undefined;
for (const [accountId, account] of Object.entries(accounts ?? {})) {
const accountCreds = (account as Record<string, unknown>).creds as
| Record<string, unknown>
| 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,

View File

@@ -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<TtsRuntimeModule> | 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<TtsRuntimeModule> {
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<typeof resolveModelAsyncMock>
>,
);
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: {