refactor: move manifest legacy migration into doctor

This commit is contained in:
Peter Steinberger
2026-03-27 02:09:43 +00:00
parent 5404b0eaa6
commit 77d15841d7
7 changed files with 330 additions and 55 deletions

View File

@@ -215,8 +215,9 @@ Each list is optional:
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
Legacy top-level `speechProviders`, `mediaUnderstandingProviders`, and
`imageGenerationProviders` still parse for compatibility, but new manifests
should declare those ids under `contracts` only.
`imageGenerationProviders` are deprecated. Use `openclaw doctor --fix` to move
them under `contracts`; normal manifest loading no longer treats them as
capability ownership.
## Manifest versus package.json

View File

@@ -70,22 +70,6 @@ function normalizeManifestContracts(raw) {
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeLegacyCapabilityContracts(raw) {
return normalizeManifestContracts({
speechProviders: raw?.speechProviders,
mediaUnderstandingProviders: raw?.mediaUnderstandingProviders,
imageGenerationProviders: raw?.imageGenerationProviders,
});
}
function mergeManifestContracts(fallback, primary) {
const merged = {
...fallback,
...primary,
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
function normalizeObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -127,11 +111,6 @@ function normalizePluginManifest(raw) {
return null;
}
const contracts = mergeManifestContracts(
normalizeLegacyCapabilityContracts(raw),
normalizeManifestContracts(raw.contracts),
);
return {
id: raw.id.trim(),
configSchema: raw.configSchema,
@@ -155,7 +134,9 @@ function normalizePluginManifest(raw) {
...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}),
...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}),
...(contracts ? { contracts } : {}),
...(normalizeManifestContracts(raw.contracts)
? { contracts: normalizeManifestContracts(raw.contracts) }
: {}),
};
}

View File

@@ -0,0 +1,141 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
import type { RuntimeEnv } from "../runtime.js";
import {
collectLegacyPluginManifestContractMigrations,
maybeRepairLegacyPluginManifestContracts,
} from "./doctor-plugin-manifests.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-doctor-plugin-manifests", tempDirs);
}
function writeManifest(dir: string, manifest: Record<string, unknown>) {
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf-8",
);
}
function writePackageJson(dir: string) {
fs.writeFileSync(
path.join(dir, "package.json"),
`${JSON.stringify(
{
name: "@openclaw/test-plugin",
version: "1.0.0",
openclaw: {
extensions: ["./index.ts"],
},
},
null,
2,
)}\n`,
"utf-8",
);
fs.writeFileSync(path.join(dir, "index.ts"), "export default {};\n", "utf-8");
}
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
}
function createPrompter(overrides: Partial<DoctorPrompter> = {}): DoctorPrompter {
return {
confirm: vi.fn(),
confirmAutoFix: vi.fn().mockResolvedValue(true),
confirmAggressiveAutoFix: vi.fn(),
confirmRuntimeRepair: vi.fn(),
select: vi.fn(),
shouldRepair: false,
shouldForce: false,
repairMode: {
shouldRepair: false,
shouldForce: false,
nonInteractive: false,
canPrompt: true,
},
...overrides,
} as unknown as DoctorPrompter;
}
describe("doctor plugin manifest legacy contract repair", () => {
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
vi.restoreAllMocks();
});
it("collects legacy top-level capability keys for migration", () => {
const pluginsRoot = makeTempDir();
const root = path.join(pluginsRoot, "openai");
fs.mkdirSync(root, { recursive: true });
writePackageJson(root);
writeManifest(root, {
id: "openai",
providers: ["openai"],
speechProviders: ["openai"],
configSchema: { type: "object" },
});
const migrations = collectLegacyPluginManifestContractMigrations({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: pluginsRoot,
},
});
expect(migrations).toHaveLength(1);
expect(migrations[0]?.changeLines).toEqual([
expect.stringContaining("moved speechProviders to contracts.speechProviders"),
]);
});
it("rewrites legacy top-level capability keys into contracts", async () => {
const pluginsRoot = makeTempDir();
const root = path.join(pluginsRoot, "openai");
fs.mkdirSync(root, { recursive: true });
writePackageJson(root);
writeManifest(root, {
id: "openai",
providers: ["openai"],
speechProviders: ["openai"],
mediaUnderstandingProviders: ["openai"],
contracts: {
webSearchProviders: ["gemini"],
},
configSchema: { type: "object" },
});
await maybeRepairLegacyPluginManifestContracts({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: pluginsRoot,
},
runtime: createRuntime(),
prompter: createPrompter(),
});
const next = JSON.parse(fs.readFileSync(path.join(root, "openclaw.plugin.json"), "utf-8")) as {
speechProviders?: string[];
mediaUnderstandingProviders?: string[];
contracts?: Record<string, string[]>;
};
expect(next.speechProviders).toBeUndefined();
expect(next.mediaUnderstandingProviders).toBeUndefined();
expect(next.contracts).toEqual({
speechProviders: ["openai"],
mediaUnderstandingProviders: ["openai"],
webSearchProviders: ["gemini"],
});
});
});

View File

@@ -0,0 +1,166 @@
import fs from "node:fs";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const LEGACY_MANIFEST_CONTRACT_KEYS = [
"speechProviders",
"mediaUnderstandingProviders",
"imageGenerationProviders",
] as const;
type LegacyManifestContractMigration = {
manifestPath: string;
pluginId: string;
nextRaw: Record<string, unknown>;
changeLines: string[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function readManifestJson(manifestPath: string): Record<string, unknown> | null {
try {
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
return isRecord(raw) ? raw : null;
} catch {
return null;
}
}
function buildLegacyManifestContractMigration(params: {
manifestPath: string;
raw: Record<string, unknown>;
}): LegacyManifestContractMigration | null {
const nextRaw = { ...params.raw };
const nextContracts = isRecord(params.raw.contracts) ? { ...params.raw.contracts } : {};
const changeLines: string[] = [];
for (const key of LEGACY_MANIFEST_CONTRACT_KEYS) {
if (!(key in params.raw)) {
continue;
}
const legacyValues = normalizeStringList(params.raw[key]);
const contractValues = normalizeStringList(nextContracts[key]);
if (legacyValues.length > 0 && contractValues.length === 0) {
nextContracts[key] = legacyValues;
changeLines.push(
`- ${shortenHomePath(params.manifestPath)}: moved ${key} to contracts.${key}`,
);
} else {
changeLines.push(
`- ${shortenHomePath(params.manifestPath)}: removed legacy ${key} (kept contracts.${key})`,
);
}
delete nextRaw[key];
}
if (changeLines.length === 0) {
return null;
}
if (Object.keys(nextContracts).length > 0) {
nextRaw.contracts = nextContracts;
} else {
delete nextRaw.contracts;
}
const pluginId = typeof params.raw.id === "string" ? params.raw.id.trim() : params.manifestPath;
return {
manifestPath: params.manifestPath,
pluginId,
nextRaw,
changeLines,
};
}
export function collectLegacyPluginManifestContractMigrations(params?: {
env?: NodeJS.ProcessEnv;
}): LegacyManifestContractMigration[] {
const seen = new Set<string>();
const migrations: LegacyManifestContractMigration[] = [];
for (const plugin of loadPluginManifestRegistry({
cache: false,
...(params?.env ? { env: params.env } : {}),
}).plugins) {
if (seen.has(plugin.manifestPath)) {
continue;
}
seen.add(plugin.manifestPath);
const raw = readManifestJson(plugin.manifestPath);
if (!raw) {
continue;
}
const migration = buildLegacyManifestContractMigration({
manifestPath: plugin.manifestPath,
raw,
});
if (migration) {
migrations.push(migration);
}
}
return migrations.toSorted((left, right) => left.manifestPath.localeCompare(right.manifestPath));
}
export async function maybeRepairLegacyPluginManifestContracts(params: {
env?: NodeJS.ProcessEnv;
runtime: RuntimeEnv;
prompter: DoctorPrompter;
}): Promise<void> {
const migrations = collectLegacyPluginManifestContractMigrations(
params.env ? { env: params.env } : undefined,
);
if (migrations.length === 0) {
return;
}
note(
[
"Legacy plugin manifest capability keys detected.",
...migrations.flatMap((migration) => migration.changeLines),
].join("\n"),
"Plugin manifests",
);
const shouldRepair =
params.prompter.shouldRepair ||
(await params.prompter.confirmAutoFix({
message: "Rewrite legacy plugin manifest capability keys into contracts now?",
initialValue: true,
}));
if (!shouldRepair) {
return;
}
const applied: string[] = [];
for (const migration of migrations) {
try {
fs.writeFileSync(
migration.manifestPath,
`${JSON.stringify(migration.nextRaw, null, 2)}\n`,
"utf-8",
);
applied.push(...migration.changeLines);
} catch (error) {
params.runtime.error(
`Failed to rewrite legacy plugin manifest at ${migration.manifestPath}: ${String(error)}`,
);
}
}
if (applied.length > 0) {
note(applied.join("\n"), "Doctor changes");
}
}

View File

@@ -28,6 +28,7 @@ import {
noteMacLaunchAgentOverrides,
noteMacLaunchctlGatewayEnvOverrides,
} from "../commands/doctor-platform-notes.js";
import { maybeRepairLegacyPluginManifestContracts } from "../commands/doctor-plugin-manifests.js";
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "../commands/doctor-sandbox.js";
import { noteSecurityWarnings } from "../commands/doctor-security.js";
@@ -234,6 +235,14 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
}
async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await maybeRepairLegacyPluginManifestContracts({
env: process.env,
runtime: ctx.runtime,
prompter: ctx.prompter,
});
}
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
}
@@ -481,6 +490,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Legacy state",
run: runLegacyStateHealth,
}),
createDoctorHealthContribution({
id: "doctor:legacy-plugin-manifests",
label: "Legacy plugin manifests",
run: runLegacyPluginManifestHealth,
}),
createDoctorHealthContribution({
id: "doctor:state-integrity",
label: "State integrity",

View File

@@ -358,7 +358,7 @@ describe("loadPluginManifestRegistry", () => {
}),
);
});
it("normalizes legacy top-level capability fields into contracts", () => {
it("does not promote legacy top-level capability fields into contracts", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "openai",
@@ -375,11 +375,7 @@ describe("loadPluginManifestRegistry", () => {
origin: "bundled",
});
expect(registry.plugins[0]?.contracts).toEqual({
speechProviders: ["openai"],
mediaUnderstandingProviders: ["openai", "openai-codex"],
imageGenerationProviders: ["openai"],
});
expect(registry.plugins[0]?.contracts).toBeUndefined();
});
it("skips plugins whose minHostVersion is newer than the current host", () => {
const dir = makeTempDir();

View File

@@ -131,27 +131,6 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
return Object.keys(contracts).length > 0 ? contracts : undefined;
}
function normalizeLegacyCapabilityContracts(
raw: Record<string, unknown>,
): PluginManifestContracts | undefined {
return normalizeManifestContracts({
speechProviders: raw.speechProviders,
mediaUnderstandingProviders: raw.mediaUnderstandingProviders,
imageGenerationProviders: raw.imageGenerationProviders,
});
}
function mergeManifestContracts(
fallback: PluginManifestContracts | undefined,
primary: PluginManifestContracts | undefined,
): PluginManifestContracts | undefined {
const merged = {
...fallback,
...primary,
} satisfies PluginManifestContracts;
return Object.keys(merged).length > 0 ? merged : undefined;
}
function normalizeProviderAuthChoices(
value: unknown,
): PluginManifestProviderAuthChoice[] | undefined {
@@ -303,10 +282,7 @@ export function loadPluginManifest(
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
const skills = normalizeStringList(raw.skills);
const contracts = mergeManifestContracts(
normalizeLegacyCapabilityContracts(raw),
normalizeManifestContracts(raw.contracts),
);
const contracts = normalizeManifestContracts(raw.contracts);
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
let uiHints: Record<string, PluginConfigUiHint> | undefined;