fix(config): apply filtered doctor compat at read time

This commit is contained in:
Vincent Koc
2026-04-06 20:45:07 +01:00
parent 78639eff76
commit 7d54f2a3c2
11 changed files with 252 additions and 50 deletions

View File

@@ -25,10 +25,8 @@ vi.mock("./manifest-registry.js", () => ({
mocks.loadPluginManifestRegistry(...args),
}));
import {
clearPluginDoctorContractRegistryCache,
listPluginDoctorLegacyConfigRules,
} from "./doctor-contract-registry.js";
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
function makeTempDir(): string {
return makeTrackedTempDir("openclaw-doctor-contract-registry", tempDirs);
@@ -39,8 +37,7 @@ afterEach(() => {
});
describe("doctor-contract-registry getJiti", () => {
beforeEach(() => {
clearPluginDoctorContractRegistryCache();
beforeEach(async () => {
mocks.createJiti.mockReset();
mocks.discoverOpenClawPlugins.mockReset();
mocks.loadPluginManifestRegistry.mockReset();
@@ -53,6 +50,10 @@ describe("doctor-contract-registry getJiti", () => {
return () => ({ default: {} });
},
);
vi.resetModules();
({ clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules } =
await import("./doctor-contract-registry.js"));
clearPluginDoctorContractRegistryCache();
});
it("disables native jiti loading on Windows for contract-api modules", () => {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import type { LegacyConfigRule } from "../config/legacy.shared.js";
import type { OpenClawConfig } from "../config/types.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
@@ -20,11 +21,22 @@ const RUNNING_FROM_BUILT_ARTIFACT =
type PluginDoctorContractModule = {
legacyConfigRules?: unknown;
normalizeCompatibilityConfig?: unknown;
};
type PluginDoctorCompatibilityMutation = {
config: OpenClawConfig;
changes: string[];
};
type PluginDoctorCompatibilityNormalizer = (params: {
cfg: OpenClawConfig;
}) => PluginDoctorCompatibilityMutation;
type PluginDoctorContractEntry = {
pluginId: string;
rules: LegacyConfigRule[];
normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer;
};
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
@@ -52,6 +64,7 @@ function getJiti(modulePath: string) {
function buildDoctorContractCacheKey(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
@@ -60,6 +73,7 @@ function buildDoctorContractCacheKey(params: {
return JSON.stringify({
roots,
loadPaths,
pluginIds: [...(params.pluginIds ?? [])].toSorted(),
});
}
@@ -67,6 +81,12 @@ function resolveContractApiPath(rootDir: string): string | null {
const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT
? CONTRACT_API_EXTENSIONS
: ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const);
for (const extension of orderedExtensions) {
const candidate = path.join(rootDir, `doctor-contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of orderedExtensions) {
const candidate = path.join(rootDir, `contract-api${extension}`);
if (fs.existsSync(candidate)) {
@@ -89,14 +109,68 @@ function coerceLegacyConfigRules(value: unknown): LegacyConfigRule[] {
}) as LegacyConfigRule[];
}
function coerceNormalizeCompatibilityConfig(
value: unknown,
): PluginDoctorCompatibilityNormalizer | undefined {
return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacyElevenLabsTalkFields(raw: unknown): boolean {
const talk = asRecord(asRecord(raw)?.talk);
if (!talk) {
return false;
}
return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) =>
Object.prototype.hasOwnProperty.call(talk, key),
);
}
export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
const ids = new Set<string>();
const root = asRecord(raw);
if (!root) {
return [];
}
const channels = asRecord(root.channels);
if (channels) {
for (const channelId of Object.keys(channels)) {
if (channelId !== "defaults") {
ids.add(channelId);
}
}
}
const pluginsEntries = asRecord(asRecord(root.plugins)?.entries);
if (pluginsEntries) {
for (const pluginId of Object.keys(pluginsEntries)) {
ids.add(pluginId);
}
}
if (hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
return [...ids].toSorted();
}
function resolvePluginDoctorContracts(params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): PluginDoctorContractEntry[] {
const env = params?.env ?? process.env;
const cacheKey = buildDoctorContractCacheKey({
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
});
const cached = doctorContractCache.get(cacheKey);
if (cached) {
@@ -117,7 +191,17 @@ function resolvePluginDoctorContracts(params?: {
});
const entries: PluginDoctorContractEntry[] = [];
const selectedPluginIds =
params?.pluginIds && params.pluginIds.length > 0 ? new Set(params.pluginIds) : null;
for (const record of manifestRegistry.plugins) {
if (
selectedPluginIds &&
!selectedPluginIds.has(record.id) &&
!record.channels.some((channelId) => selectedPluginIds.has(channelId)) &&
!record.providers.some((providerId) => selectedPluginIds.has(providerId))
) {
continue;
}
const contractSource = resolveContractApiPath(record.rootDir);
if (!contractSource) {
continue;
@@ -132,12 +216,17 @@ function resolvePluginDoctorContracts(params?: {
(mod as { default?: PluginDoctorContractModule }).default?.legacyConfigRules ??
mod.legacyConfigRules,
);
if (rules.length === 0) {
const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig(
mod.normalizeCompatibilityConfig ??
(mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig,
);
if (rules.length === 0 && !normalizeCompatibilityConfig) {
continue;
}
entries.push({
pluginId: record.id,
rules,
normalizeCompatibilityConfig,
});
}
@@ -147,11 +236,37 @@ function resolvePluginDoctorContracts(params?: {
export function clearPluginDoctorContractRegistryCache(): void {
doctorContractCache.clear();
jitiLoaders.clear();
}
export function listPluginDoctorLegacyConfigRules(params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): LegacyConfigRule[] {
return resolvePluginDoctorContracts(params).flatMap((entry) => entry.rules);
}
export function applyPluginDoctorCompatibilityMigrations(
cfg: OpenClawConfig,
params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
},
): {
config: OpenClawConfig;
changes: string[];
} {
let nextCfg = cfg;
const changes: string[] = [];
for (const entry of resolvePluginDoctorContracts(params)) {
const mutation = entry.normalizeCompatibilityConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) {
continue;
}
nextCfg = mutation.config;
changes.push(...mutation.changes);
}
return { config: nextCfg, changes };
}