fix(plugins): preflight registry install migration

This commit is contained in:
Vincent Koc
2026-04-25 03:30:13 -07:00
parent 81aefb9a18
commit d7cf803705
7 changed files with 371 additions and 129 deletions

View File

@@ -32,7 +32,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions");
const DEFAULT_PACKAGE_ROOT = join(__dirname, "..");
const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL";
const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION";
const EAGER_BUNDLED_PLUGIN_DEPS_ENV = "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS";
const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json";
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
@@ -662,45 +661,34 @@ async function importInstalledDistModule(params, distPath) {
}
export async function runPluginRegistryPostinstallMigration(params = {}) {
const env = params.env ?? process.env;
const log = params.log ?? console;
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
if (env?.[DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV]?.trim()) {
return { status: "disabled" };
}
try {
const [configModule, registryModule] = await Promise.all([
importInstalledDistModule(params, "dist/config/config.js"),
importInstalledDistModule(params, "dist/plugins/plugin-registry.js"),
]);
if (!configModule || !registryModule) {
const migrationModule = await importInstalledDistModule(
params,
"dist/commands/doctor/shared/plugin-registry-migration.js",
);
if (!migrationModule) {
return { status: "skipped", reason: "missing-dist-entry" };
}
const readConfig =
typeof configModule.readBestEffortConfig === "function"
? configModule.readBestEffortConfig
: configModule.loadConfig;
if (
typeof readConfig !== "function" ||
typeof registryModule.ensurePluginRegistryMigrated !== "function"
) {
if (typeof migrationModule.migratePluginRegistryForInstall !== "function") {
return { status: "skipped", reason: "missing-dist-contract" };
}
const config = await readConfig();
const inspection = await registryModule.ensurePluginRegistryMigrated({
config,
env,
const result = await migrationModule.migratePluginRegistryForInstall({
env: params.env ?? process.env,
packageRoot,
});
if (inspection.migrated) {
log.log(
`[postinstall] migrated plugin registry: ${inspection.current.plugins.length} plugin(s) indexed`,
);
return { status: "migrated", inspection };
for (const warning of result.preflight?.deprecationWarnings ?? []) {
log.warn(`[postinstall] ${warning}`);
}
return { status: "fresh", inspection };
if (result.migrated) {
log.log(
`[postinstall] migrated plugin registry: ${result.current.plugins.length} plugin(s) indexed`,
);
}
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`[postinstall] could not migrate plugin registry: ${message}`);

View File

@@ -0,0 +1,172 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "../../../plugins/discovery.js";
import { readPersistedInstalledPluginIndex } from "../../../plugins/installed-plugin-index-store.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
} from "../../../plugins/test-helpers/fs-fixtures.js";
import {
FORCE_PLUGIN_REGISTRY_MIGRATION_ENV,
migratePluginRegistryForInstall,
preflightPluginRegistryInstallMigration,
} from "./plugin-registry-migration.js";
const tempDirs: string[] = [];
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
function makeTempDir() {
return makeTrackedTempDir("openclaw-plugin-registry-migration", tempDirs);
}
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
...overrides,
};
}
function createCandidate(rootDir: string): PluginCandidate {
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load while migrating plugin registry');\n",
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
configSchema: { type: "object" },
providers: ["demo"],
}),
"utf8",
);
return {
idHint: "demo",
source: path.join(rootDir, "index.ts"),
rootDir,
origin: "global",
};
}
describe("plugin registry install migration", () => {
it("short-circuits when a registry file already exists", async () => {
const stateDir = makeTempDir();
const filePath = path.join(stateDir, "plugins", "installed-index.json");
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "{}\n", "utf8");
const readConfig = vi.fn(async () => ({}));
await expect(
migratePluginRegistryForInstall({
stateDir,
readConfig,
env: hermeticEnv(),
}),
).resolves.toMatchObject({
status: "skip-existing",
migrated: false,
preflight: {
action: "skip-existing",
filePath,
},
});
expect(readConfig).not.toHaveBeenCalled();
});
it("supports dry-run preflight without reading config or writing the registry", async () => {
const stateDir = makeTempDir();
const readConfig = vi.fn(async () => ({}));
await expect(
migratePluginRegistryForInstall({
stateDir,
dryRun: true,
readConfig,
env: hermeticEnv(),
}),
).resolves.toMatchObject({
status: "dry-run",
migrated: false,
preflight: {
action: "migrate",
},
});
expect(readConfig).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(stateDir, "plugins", "installed-index.json"))).toBe(false);
});
it("migrates missing registry state from legacy discovery and config inputs", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
const candidate = createCandidate(pluginDir);
await expect(
migratePluginRegistryForInstall({
stateDir,
candidates: [candidate],
readConfig: async () => ({
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
},
}),
env: hermeticEnv(),
}),
).resolves.toMatchObject({
status: "migrated",
migrated: true,
current: {
refreshReason: "migration",
migrationVersion: 1,
plugins: [
expect.objectContaining({
pluginId: "demo",
installRecord: expect.objectContaining({
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
}),
}),
],
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
refreshReason: "migration",
plugins: [expect.objectContaining({ pluginId: "demo" })],
});
});
it("marks force migration env as deprecated break-glass", () => {
expect(
preflightPluginRegistryInstallMigration({
stateDir: makeTempDir(),
env: hermeticEnv({
[FORCE_PLUGIN_REGISTRY_MIGRATION_ENV]: "1",
}),
}),
).toMatchObject({
action: "migrate",
force: true,
deprecationWarnings: [
expect.stringContaining(`${FORCE_PLUGIN_REGISTRY_MIGRATION_ENV} is deprecated`),
],
});
});
});

View File

@@ -0,0 +1,135 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
inspectPersistedInstalledPluginIndex,
refreshPersistedInstalledPluginIndex,
resolveInstalledPluginIndexStorePath,
type InstalledPluginIndexStoreInspection,
type InstalledPluginIndexStoreOptions,
} from "../../../plugins/installed-plugin-index-store.js";
import type {
InstalledPluginIndex,
LoadInstalledPluginIndexParams,
} from "../../../plugins/installed-plugin-index.js";
export const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION";
export const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION";
export type PluginRegistryInstallMigrationPreflightAction =
| "disabled"
| "skip-existing"
| "migrate";
export type PluginRegistryInstallMigrationPreflight = {
action: PluginRegistryInstallMigrationPreflightAction;
filePath: string;
force: boolean;
deprecationWarnings: readonly string[];
};
export type PluginRegistryInstallMigrationResult =
| {
status: "disabled" | "skip-existing" | "dry-run";
migrated: false;
preflight: PluginRegistryInstallMigrationPreflight;
}
| {
status: "migrated";
migrated: true;
preflight: PluginRegistryInstallMigrationPreflight;
inspection: InstalledPluginIndexStoreInspection;
current: InstalledPluginIndex;
};
export type PluginRegistryInstallMigrationParams = LoadInstalledPluginIndexParams &
InstalledPluginIndexStoreOptions & {
dryRun?: boolean;
existsSync?: (path: string) => boolean;
readConfig?: () => Promise<OpenClawConfig> | OpenClawConfig;
};
function hasEnvFlag(env: NodeJS.ProcessEnv | undefined, key: string): boolean {
return Boolean(env?.[key]?.trim());
}
function forceDeprecationWarning(): string {
return `${FORCE_PLUGIN_REGISTRY_MIGRATION_ENV} is deprecated and will be removed after the plugin registry migration rollout; use doctor registry repair once available.`;
}
export function preflightPluginRegistryInstallMigration(
params: PluginRegistryInstallMigrationParams = {},
): PluginRegistryInstallMigrationPreflight {
const env = params.env ?? process.env;
const filePath = resolveInstalledPluginIndexStorePath(params);
const force = hasEnvFlag(env, FORCE_PLUGIN_REGISTRY_MIGRATION_ENV);
const deprecationWarnings = force ? [forceDeprecationWarning()] : [];
if (hasEnvFlag(env, DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV)) {
return {
action: "disabled",
filePath,
force,
deprecationWarnings,
};
}
const pathExists = params.existsSync ?? fs.existsSync;
if (!force && pathExists(filePath)) {
return {
action: "skip-existing",
filePath,
force,
deprecationWarnings,
};
}
return {
action: "migrate",
filePath,
force,
deprecationWarnings,
};
}
async function readMigrationConfig(
params: PluginRegistryInstallMigrationParams,
): Promise<OpenClawConfig> {
if (params.config) {
return params.config;
}
if (params.readConfig) {
return await params.readConfig();
}
const configModule = await import("../../../config/config.js");
return await configModule.readBestEffortConfig();
}
export async function migratePluginRegistryForInstall(
params: PluginRegistryInstallMigrationParams = {},
): Promise<PluginRegistryInstallMigrationResult> {
const preflight = preflightPluginRegistryInstallMigration(params);
if (preflight.action === "disabled") {
return { status: "disabled", migrated: false, preflight };
}
if (preflight.action === "skip-existing") {
return { status: "skip-existing", migrated: false, preflight };
}
if (params.dryRun) {
return { status: "dry-run", migrated: false, preflight };
}
const config = await readMigrationConfig(params);
const migrationParams = {
...params,
config,
};
const inspection = await inspectPersistedInstalledPluginIndex(migrationParams);
const current = await refreshPersistedInstalledPluginIndex({
...migrationParams,
reason: "migration",
});
return {
status: "migrated",
migrated: true,
preflight,
inspection,
current,
};
}

View File

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import {
getPluginRecord,
ensurePluginRegistryMigrated,
inspectPluginRegistry,
isPluginEnabled,
listPluginContributionIds,
@@ -187,36 +186,4 @@ describe("plugin registry facade", () => {
},
});
});
it("migrates missing persisted registry state from legacy discovery inputs", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
const candidate = createCandidate(pluginDir);
const env = hermeticEnv();
await expect(
ensurePluginRegistryMigrated({ stateDir, candidates: [candidate], env }),
).resolves.toMatchObject({
state: "missing",
refreshReasons: ["missing"],
migrated: true,
current: {
refreshReason: "migration",
migrationVersion: 1,
plugins: [expect.objectContaining({ pluginId: "demo", enabled: true })],
},
});
await expect(
inspectPluginRegistry({ stateDir, candidates: [candidate], env }),
).resolves.toMatchObject({
state: "fresh",
refreshReasons: [],
persisted: {
refreshReason: "migration",
plugins: [expect.objectContaining({ pluginId: "demo" })],
},
});
});
});

View File

@@ -20,9 +20,6 @@ import {
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
export type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
export type PluginRegistryMigrationInspection = PluginRegistryInspection & {
migrated: boolean;
};
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & {
index?: PluginRegistrySnapshot;
@@ -177,31 +174,3 @@ export function refreshPluginRegistry(
store.refreshPersistedInstalledPluginIndex(params),
);
}
function resolveMigrationRefreshReason(
reasons: readonly RefreshInstalledPluginIndexParams["reason"][],
): RefreshInstalledPluginIndexParams["reason"] {
return reasons.includes("missing") || reasons.includes("migration") ? "migration" : "manual";
}
export async function ensurePluginRegistryMigrated(
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
): Promise<PluginRegistryMigrationInspection> {
const store = await import("./installed-plugin-index-store.js");
const inspection = await store.inspectPersistedInstalledPluginIndex(params);
if (inspection.state === "fresh") {
return {
...inspection,
migrated: false,
};
}
const current = await store.refreshPersistedInstalledPluginIndex({
...params,
reason: resolveMigrationRefreshReason(inspection.refreshReasons),
});
return {
...inspection,
current,
migrated: true,
};
}

View File

@@ -251,39 +251,29 @@ describe("bundled plugin postinstall", () => {
it("migrates the plugin registry during postinstall from built dist contracts", async () => {
const packageRoot = await createTempDirAsync("openclaw-postinstall-registry-");
const log = { log: vi.fn(), warn: vi.fn() };
const readBestEffortConfig = vi.fn(async () => ({
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
},
}));
const ensurePluginRegistryMigrated = vi.fn(async () => ({
const migratePluginRegistryForInstall = vi.fn(async () => ({
status: "migrated",
migrated: true,
preflight: {
deprecationWarnings: [],
},
current: {
plugins: [{ pluginId: "demo" }],
},
}));
const importModule = vi.fn(async (specifier: string) => {
if (specifier.endsWith("/dist/config/config.js")) {
return { readBestEffortConfig };
}
if (specifier.endsWith("/dist/plugins/plugin-registry.js")) {
return { ensurePluginRegistryMigrated };
if (specifier.endsWith("/dist/commands/doctor/shared/plugin-registry-migration.js")) {
return { migratePluginRegistryForInstall };
}
throw new Error(`unexpected import: ${specifier}`);
});
const result = await runPluginRegistryPostinstallMigration({
packageRoot,
existsSync: vi.fn(
(filePath: string) =>
filePath.endsWith(path.join("dist", "config", "config.js")) ||
filePath.endsWith(path.join("dist", "plugins", "plugin-registry.js")),
existsSync: vi.fn((filePath: string) =>
filePath.endsWith(
path.join("dist", "commands", "doctor", "shared", "plugin-registry-migration.js"),
),
),
importModule,
env: { OPENCLAW_HOME: "/tmp/home" },
@@ -291,19 +281,7 @@ describe("bundled plugin postinstall", () => {
});
expect(result).toMatchObject({ status: "migrated" });
expect(readBestEffortConfig).toHaveBeenCalled();
expect(ensurePluginRegistryMigrated).toHaveBeenCalledWith({
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
},
},
expect(migratePluginRegistryForInstall).toHaveBeenCalledWith({
env: { OPENCLAW_HOME: "/tmp/home" },
packageRoot,
});
@@ -312,6 +290,29 @@ describe("bundled plugin postinstall", () => {
);
});
it("surfaces deprecated plugin registry migration break-glass warnings", async () => {
const warn = vi.fn();
const migratePluginRegistryForInstall = vi.fn(async () => ({
status: "skip-existing",
migrated: false,
preflight: {
deprecationWarnings: ["OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated"],
},
}));
const importModule = vi.fn(async () => ({ migratePluginRegistryForInstall }));
await runPluginRegistryPostinstallMigration({
packageRoot: "/pkg",
existsSync: vi.fn(() => true),
importModule,
log: { log: vi.fn(), warn },
});
expect(warn).toHaveBeenCalledWith(
"[postinstall] OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated",
);
});
it("keeps plugin registry postinstall migration non-fatal when dist entries are unavailable", async () => {
const warn = vi.fn();
@@ -329,12 +330,22 @@ describe("bundled plugin postinstall", () => {
});
it("honors plugin registry postinstall migration disable env", async () => {
const migratePluginRegistryForInstall = vi.fn(async () => ({
status: "disabled",
migrated: false,
preflight: {
deprecationWarnings: [],
},
}));
await expect(
runPluginRegistryPostinstallMigration({
packageRoot: "/pkg",
env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "1" },
existsSync: vi.fn(() => true),
importModule: vi.fn(async () => ({ migratePluginRegistryForInstall })),
log: { log: vi.fn(), warn: vi.fn() },
}),
).resolves.toEqual({ status: "disabled" });
).resolves.toMatchObject({ status: "disabled" });
});
it("prunes stale dist files from packaged installs", async () => {

View File

@@ -214,10 +214,10 @@ function buildCoreDistEntries(): Record<string, string> {
"agents/models-config.runtime": "src/agents/models-config.runtime.ts",
"subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts",
"agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts",
"commands/doctor/shared/plugin-registry-migration":
"src/commands/doctor/shared/plugin-registry-migration.ts",
"commands/status.summary.runtime": "src/commands/status.summary.runtime.ts",
"config/config": "src/config/config.ts",
"infra/boundary-file-read": "src/infra/boundary-file-read.ts",
"plugins/plugin-registry": "src/plugins/plugin-registry.ts",
"plugins/provider-discovery.runtime": "src/plugins/provider-discovery.runtime.ts",
"plugins/provider-runtime.runtime": "src/plugins/provider-runtime.runtime.ts",
"plugins/public-surface-runtime": "src/plugins/public-surface-runtime.ts",