mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
feat(plugins): migrate plugin registry on install
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user