mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
perf: narrow plugin config test surfaces
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) ||
|
||||
|
||||
@@ -52,6 +52,12 @@ describe("collectPluginConfigAssignments", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
origin: "config",
|
||||
providers: [],
|
||||
legacyPluginIds: [],
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user