feat(plugins): migrate plugin registry on install

This commit is contained in:
Vincent Koc
2026-04-25 02:39:02 -07:00
parent a48998d8c8
commit 81aefb9a18
10 changed files with 246 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg.
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
- CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend.
- Plugins: migrate the local plugin registry automatically during package install/update, preserving legacy config and install-ledger state while indexing existing plugin manifests for the new cold registry path. Thanks @vincentkoc.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna.

View File

@@ -32,6 +32,7 @@ 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("-");
@@ -645,6 +646,68 @@ function applyBundledPluginRuntimeHotfixes(params = {}) {
}
}
function resolveDistModuleUrl(packageRoot, distPath) {
return pathToFileURL(join(packageRoot, distPath)).href;
}
async function importInstalledDistModule(params, distPath) {
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
const pathExists = params.existsSync ?? existsSync;
const modulePath = join(packageRoot, distPath);
if (!pathExists(modulePath)) {
return null;
}
const importModule = params.importModule ?? ((specifier) => import(specifier));
return await importModule(resolveDistModuleUrl(packageRoot, 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) {
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"
) {
return { status: "skipped", reason: "missing-dist-contract" };
}
const config = await readConfig();
const inspection = await registryModule.ensurePluginRegistryMigrated({
config,
env,
packageRoot,
});
if (inspection.migrated) {
log.log(
`[postinstall] migrated plugin registry: ${inspection.current.plugins.length} plugin(s) indexed`,
);
return { status: "migrated", inspection };
}
return { status: "fresh", inspection };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`[postinstall] could not migrate plugin registry: ${message}`);
return { status: "failed", error: message };
}
}
export function isSourceCheckoutRoot(params) {
const pathExists = params.existsSync ?? existsSync;
return (
@@ -836,4 +899,5 @@ export function isDirectPostinstallInvocation(params = {}) {
if (isDirectPostinstallInvocation()) {
runBundledPluginPostinstall();
await runPluginRegistryPostinstallMigration();
}

View File

@@ -27,6 +27,7 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [
@@ -113,6 +114,17 @@ describe("installed plugin index persistence", () => {
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();
});
it("rejects pre-migration persisted indexes so update can rebuild them", async () => {
const stateDir = makeTempDir();
const filePath = resolveInstalledPluginIndexStorePath({ stateDir });
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const legacyIndex = createIndex();
delete (legacyIndex as unknown as Record<string, unknown>).migrationVersion;
fs.writeFileSync(filePath, JSON.stringify(legacyIndex), "utf8");
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();
});
it("inspects missing, fresh, and stale persisted index state without loading runtime", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");

View File

@@ -6,6 +6,7 @@ import { safeParseWithSchema } from "../utils/zod-parse.js";
import {
diffInstalledPluginIndexInvalidationReasons,
INSTALLED_PLUGIN_INDEX_VERSION,
INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
loadInstalledPluginIndex,
refreshInstalledPluginIndex,
type InstalledPluginIndex,
@@ -85,6 +86,7 @@ const InstalledPluginIndexSchema = z
version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION),
hostContractVersion: z.string(),
compatRegistryVersion: z.string(),
migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION),
policyHash: z.string(),
generatedAtMs: z.number(),
refreshReason: z.string().optional(),

View File

@@ -154,6 +154,7 @@ describe("installed plugin index", () => {
expect(index).toMatchObject({
version: 1,
migrationVersion: 1,
generatedAtMs: 1777118400000,
plugins: [
{
@@ -557,7 +558,7 @@ describe("installed plugin index", () => {
expect(index.refreshReason).toBe("manual");
});
it("diffs invalidation reasons for manifest, package, source, host, and compat changes", () => {
it("diffs invalidation reasons for manifest, package, source, host, compat, and migration changes", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
@@ -604,11 +605,13 @@ describe("installed plugin index", () => {
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
}),
compatRegistryVersion: "different-compat-registry",
migrationVersion: 2 as 1,
};
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
"compat-registry-changed",
"host-contract-changed",
"migration",
"source-changed",
"stale-manifest",
"stale-package",

View File

@@ -23,6 +23,7 @@ import {
import type { PluginDiagnostic } from "./manifest-types.js";
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1;
export type InstalledPluginIndexRefreshReason =
| "missing"
@@ -30,6 +31,7 @@ export type InstalledPluginIndexRefreshReason =
| "stale-package"
| "source-changed"
| "policy-changed"
| "migration"
| "host-contract-changed"
| "compat-registry-changed"
| "manual";
@@ -103,6 +105,7 @@ export type InstalledPluginIndex = {
version: typeof INSTALLED_PLUGIN_INDEX_VERSION;
hostContractVersion: string;
compatRegistryVersion: string;
migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION;
policyHash: string;
generatedAtMs: number;
refreshReason?: InstalledPluginIndexRefreshReason;
@@ -491,6 +494,7 @@ function buildInstalledPluginIndex(
version: INSTALLED_PLUGIN_INDEX_VERSION,
hostContractVersion: resolveCompatibilityHostVersion(env),
compatRegistryVersion: resolveCompatRegistryVersion(),
migrationVersion: INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
policyHash: resolvePolicyHash(params.config),
generatedAtMs,
...(params.refreshReason ? { refreshReason: params.refreshReason } : {}),
@@ -692,6 +696,9 @@ export function diffInstalledPluginIndexInvalidationReasons(
if (previous.compatRegistryVersion !== current.compatRegistryVersion) {
reasons.add("compat-registry-changed");
}
if (previous.migrationVersion !== current.migrationVersion) {
reasons.add("migration");
}
if (previous.policyHash !== current.policyHash) {
reasons.add("policy-changed");
}

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import {
getPluginRecord,
ensurePluginRegistryMigrated,
inspectPluginRegistry,
isPluginEnabled,
listPluginContributionIds,
@@ -186,4 +187,36 @@ 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,6 +20,9 @@ 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;
@@ -174,3 +177,31 @@ 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

@@ -10,6 +10,7 @@ import {
discoverBundledPluginRuntimeDeps,
pruneBundledPluginSourceNodeModules,
runBundledPluginPostinstall,
runPluginRegistryPostinstallMigration,
restoreLegacyUpdaterCompatSidecars,
} from "../../scripts/postinstall-bundled-plugins.mjs";
import { NPM_UPDATE_COMPAT_SIDECARS } from "../../src/infra/npm-update-compat-sidecars.ts";
@@ -247,6 +248,95 @@ describe("bundled plugin postinstall", () => {
await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).resolves.toBeTruthy();
});
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 () => ({
migrated: true,
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 };
}
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")),
),
importModule,
env: { OPENCLAW_HOME: "/tmp/home" },
log,
});
expect(result).toMatchObject({ status: "migrated" });
expect(readBestEffortConfig).toHaveBeenCalled();
expect(ensurePluginRegistryMigrated).toHaveBeenCalledWith({
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
},
},
env: { OPENCLAW_HOME: "/tmp/home" },
packageRoot,
});
expect(log.log).toHaveBeenCalledWith(
"[postinstall] migrated plugin registry: 1 plugin(s) indexed",
);
});
it("keeps plugin registry postinstall migration non-fatal when dist entries are unavailable", async () => {
const warn = vi.fn();
await expect(
runPluginRegistryPostinstallMigration({
packageRoot: "/pkg",
existsSync: vi.fn(() => false),
log: { log: vi.fn(), warn },
}),
).resolves.toEqual({
status: "skipped",
reason: "missing-dist-entry",
});
expect(warn).not.toHaveBeenCalled();
});
it("honors plugin registry postinstall migration disable env", async () => {
await expect(
runPluginRegistryPostinstallMigration({
env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "1" },
log: { log: vi.fn(), warn: vi.fn() },
}),
).resolves.toEqual({ status: "disabled" });
});
it("prunes stale dist files from packaged installs", async () => {
const packageRoot = await createTempDirAsync("openclaw-packaged-install-");
const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js");

View File

@@ -215,7 +215,9 @@ function buildCoreDistEntries(): Record<string, string> {
"subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts",
"agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.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",