From 81aefb9a1874a06dbf779a5245e54c3cd3e97118 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 02:39:02 -0700 Subject: [PATCH] feat(plugins): migrate plugin registry on install --- CHANGELOG.md | 1 + scripts/postinstall-bundled-plugins.mjs | 64 +++++++++++++ .../installed-plugin-index-store.test.ts | 12 +++ src/plugins/installed-plugin-index-store.ts | 2 + src/plugins/installed-plugin-index.test.ts | 5 +- src/plugins/installed-plugin-index.ts | 7 ++ src/plugins/plugin-registry.test.ts | 33 +++++++ src/plugins/plugin-registry.ts | 31 +++++++ .../postinstall-bundled-plugins.test.ts | 90 +++++++++++++++++++ tsdown.config.ts | 2 + 10 files changed, 246 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e49a35b28a..f31f1686431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index c6e5ae05394..ac0a755c3ca 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -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(); } diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 1a97f406b55..e635ba0ff60 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -27,6 +27,7 @@ function createIndex(overrides: Partial = {}): 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).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"); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 0c97f808797..0ae846971e5 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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(), diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 678b27d16b2..1b802ee56c1 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -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", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 87d028a419a..95611bcbd3b 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -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"); } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index ebdabb20578..26c6f3a245f 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -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" })], + }, + }); + }); }); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 9ab3048c034..00884ab3840 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -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 { + 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, + }; +} diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 24d6fba7465..89df22acf54 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -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"); diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d7973cca81..7953f85372e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -215,7 +215,9 @@ function buildCoreDistEntries(): Record { "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",