fix(plugins): add doctor registry repair

This commit is contained in:
Vincent Koc
2026-04-25 12:15:40 -07:00
parent 5c3eecfea7
commit 793b58b3f1
11 changed files with 391 additions and 6 deletions

View File

@@ -20,6 +20,9 @@ Docs: https://docs.openclaw.ai
- 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.
- Plugins/doctor: make `openclaw doctor --fix` move legacy `plugins.installs`
config records into the managed plugin install ledger and refresh the cold
registry index when needed. Thanks @vincentkoc.
- Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc.
- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc.
- Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc.

View File

@@ -920,6 +920,9 @@ source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. Legacy `plugins.installs` config entries are still read as a
compatibility fallback while the state-managed `plugins/installs.json` ledger
becomes the install source of truth.
`openclaw doctor --fix` migrates those legacy config entries into the managed
ledger and refreshes the cold registry index without loading plugin runtime
modules.
## Context engine plugins

View File

@@ -266,6 +266,7 @@ openclaw plugins info <id> # inspect alias
openclaw plugins doctor # diagnostics
openclaw plugins registry # inspect persisted registry state
openclaw plugins registry --refresh # rebuild persisted registry
openclaw doctor --fix # repair registry/ledger migration state
openclaw plugins install <package> # install (ClawHub first, then npm)
openclaw plugins install clawhub:<pkg> # install from ClawHub only
@@ -279,7 +280,7 @@ openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id-or-npm-spec> # update one plugin
openclaw plugins update <id-or-npm-spec> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins uninstall <id> # remove config/install records
openclaw plugins uninstall <id> # remove config and install ledger records
openclaw plugins uninstall <id> --keep-files
openclaw plugins marketplace list <source>
openclaw plugins marketplace list <source> --json
@@ -307,6 +308,9 @@ uninstall, enable, and disable flows refresh that registry after changing plugin
state. If the registry is missing, stale, or invalid, `openclaw plugins registry
--refresh` rebuilds it from the durable install ledger, config policy, and
manifest/package metadata without loading plugin runtime modules.
If a machine still has legacy `plugins.installs` records in config, run
`openclaw doctor --fix` to move them into the managed
`plugins/installs.json` ledger and remove the config copy.
`openclaw plugins update <id-or-npm-spec>` applies to tracked installs. Passing
an npm package spec with a dist-tag or exact version resolves the package name

View File

@@ -0,0 +1,149 @@
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 { readPersistedPluginInstallLedger } from "../plugins/install-ledger-store.js";
import {
readPersistedInstalledPluginIndex,
writePersistedInstalledPluginIndex,
} from "../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
import { note } from "../terminal/note.js";
import { maybeRepairPluginRegistryState } from "./doctor-plugin-registry.js";
vi.mock("../terminal/note.js", () => ({
note: vi.fn(),
}));
const tempDirs: string[] = [];
afterEach(() => {
vi.mocked(note).mockReset();
cleanupTrackedTempDirs(tempDirs);
});
function makeTempDir() {
return makeTrackedTempDir("openclaw-doctor-plugin-registry", 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, id = "demo"): PluginCandidate {
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load during doctor registry repair');\n",
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id,
name: id,
configSchema: { type: "object" },
providers: [id],
}),
"utf8",
);
return {
idHint: id,
source: path.join(rootDir, "index.ts"),
rootDir,
origin: "global",
};
}
function createCurrentIndex(): InstalledPluginIndex {
return {
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 2,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [],
diagnostics: [],
};
}
describe("maybeRepairPluginRegistryState", () => {
it("reports legacy config install records without mutating state outside repair mode", async () => {
const stateDir = makeTempDir();
const nextConfig = await maybeRepairPluginRegistryState({
stateDir,
env: hermeticEnv(),
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
},
},
},
},
prompter: { shouldRepair: false },
});
expect(nextConfig.plugins?.installs?.demo?.resolvedName).toBe("@vendor/demo");
await expect(readPersistedPluginInstallLedger({ stateDir })).resolves.toBeNull();
expect(vi.mocked(note).mock.calls.join("\n")).toContain("plugins.installs");
});
it("moves legacy config install records into the ledger and refreshes an existing registry", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir });
const nextConfig = await maybeRepairPluginRegistryState({
stateDir,
candidates: [createCandidate(pluginDir)],
env: hermeticEnv(),
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
},
},
prompter: { shouldRepair: true },
});
expect(nextConfig.plugins?.installs).toBeUndefined();
await expect(readPersistedPluginInstallLedger({ stateDir })).resolves.toMatchObject({
records: {
demo: {
source: "npm",
resolvedName: "@vendor/demo",
resolvedVersion: "1.0.0",
},
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
refreshReason: "migration",
plugins: [
expect.objectContaining({
pluginId: "demo",
installRecord: expect.objectContaining({
source: "npm",
resolvedName: "@vendor/demo",
}),
}),
],
});
});
});

View File

@@ -0,0 +1,158 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
readPersistedPluginInstallLedger,
resolvePluginInstallLedgerStorePath,
withoutPluginInstallRecords,
writePersistedPluginInstallLedger,
type PluginInstallLedgerStoreOptions,
} from "../plugins/install-ledger-store.js";
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import {
DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV,
migratePluginRegistryForInstall,
preflightPluginRegistryInstallMigration,
type PluginRegistryInstallMigrationParams,
} from "./doctor/shared/plugin-registry-migration.js";
type PluginRegistryDoctorRepairParams = Omit<PluginRegistryInstallMigrationParams, "config"> &
PluginInstallLedgerStoreOptions & {
config: OpenClawConfig;
prompter: Pick<DoctorPrompter, "shouldRepair">;
};
type LegacyInstallLedgerMigrationResult = {
config: OpenClawConfig;
migrated: boolean;
recordCount: number;
};
function countRecords(records: Record<string, unknown> | undefined): number {
return Object.keys(records ?? {}).length;
}
function mergeInstallRecords(
legacyRecords: Record<string, PluginInstallRecord>,
ledgerRecords: Record<string, PluginInstallRecord> | undefined,
): Record<string, PluginInstallRecord> {
return {
...legacyRecords,
...(ledgerRecords ?? {}),
};
}
async function maybeMigrateLegacyInstallLedger(
params: PluginRegistryDoctorRepairParams,
): Promise<LegacyInstallLedgerMigrationResult> {
const legacyRecords = params.config.plugins?.installs;
const legacyCount = countRecords(legacyRecords);
if (!legacyRecords || legacyCount === 0) {
return {
config: params.config,
migrated: false,
recordCount: 0,
};
}
const ledgerPath = resolvePluginInstallLedgerStorePath(params);
if (!params.prompter.shouldRepair) {
note(
[
`Legacy plugin install records still live in config at \`plugins.installs\`.`,
`Repair with ${formatCliCommand("openclaw doctor --fix")} to move them to ${shortenHomePath(ledgerPath)} and remove the config copy.`,
].join("\n"),
"Plugin registry",
);
return {
config: params.config,
migrated: false,
recordCount: legacyCount,
};
}
const existingLedger = await readPersistedPluginInstallLedger(params);
const nextRecords = mergeInstallRecords(legacyRecords, existingLedger?.records);
await writePersistedPluginInstallLedger(nextRecords, params);
const nextConfig = withoutPluginInstallRecords(params.config);
note(
[
`Moved ${legacyCount} legacy plugin install record${legacyCount === 1 ? "" : "s"} from config to ${shortenHomePath(ledgerPath)}.`,
"Removed the legacy `plugins.installs` config copy.",
].join("\n"),
"Plugin registry",
);
return {
config: nextConfig,
migrated: true,
recordCount: legacyCount,
};
}
export async function maybeRepairPluginRegistryState(
params: PluginRegistryDoctorRepairParams,
): Promise<OpenClawConfig> {
let nextConfig = params.config;
const ledgerMigration = await maybeMigrateLegacyInstallLedger(params);
nextConfig = ledgerMigration.config;
const migrationParams = {
...params,
config: nextConfig,
};
const preflight = preflightPluginRegistryInstallMigration(migrationParams);
for (const warning of preflight.deprecationWarnings) {
note(warning, "Plugin registry");
}
if (preflight.action === "disabled") {
note(
`${DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV} is set; skipping plugin registry repair.`,
"Plugin registry",
);
return nextConfig;
}
if (!params.prompter.shouldRepair) {
if (preflight.action === "migrate") {
note(
[
"Persisted plugin registry is missing or stale.",
`Repair with ${formatCliCommand("openclaw doctor --fix")} to rebuild ${shortenHomePath(preflight.filePath)} from enabled plugins.`,
].join("\n"),
"Plugin registry",
);
}
return nextConfig;
}
if (preflight.action === "migrate") {
const result = await migratePluginRegistryForInstall(migrationParams);
if (result.migrated) {
const total = result.current.plugins.length;
const enabled = result.current.plugins.filter((plugin) => plugin.enabled).length;
note(
`Plugin registry rebuilt: ${enabled}/${total} enabled plugins indexed.`,
"Plugin registry",
);
}
return nextConfig;
}
if (ledgerMigration.migrated) {
const index = await refreshPluginRegistry({
...migrationParams,
reason: "migration",
});
const total = index.plugins.length;
const enabled = index.plugins.filter((plugin) => plugin.enabled).length;
note(
`Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`,
"Plugin registry",
);
}
return nextConfig;
}

View File

@@ -13,4 +13,11 @@ describe("doctor health contributions", () => {
ids.indexOf("doctor:startup-channel-maintenance"),
);
});
it("runs plugin registry repair before final config writes", () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
expect(ids.indexOf("doctor:plugin-registry")).toBeGreaterThan(-1);
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
});

View File

@@ -223,6 +223,15 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom
});
}
async function runPluginRegistryHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairPluginRegistryState } = await import("../commands/doctor-plugin-registry.js");
ctx.cfg = await maybeRepairPluginRegistryState({
config: ctx.cfg,
env: process.env,
prompter: ctx.prompter,
});
}
async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairBundledPluginRuntimeDeps } =
await import("../commands/doctor-bundled-plugin-runtime-deps.js");
@@ -548,6 +557,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Legacy plugin manifests",
run: runLegacyPluginManifestHealth,
}),
createDoctorHealthContribution({
id: "doctor:plugin-registry",
label: "Plugin registry",
run: runPluginRegistryHealth,
}),
createDoctorHealthContribution({
id: "doctor:state-integrity",
label: "State integrity",

View File

@@ -202,6 +202,8 @@ function matchRule(path: string): ReloadRule | null {
}
function isPluginInstallTimestampPath(path: string): boolean {
// Legacy compatibility only: new plugin install metadata lives in the
// managed install ledger, but old config writes may still touch this path.
return /^plugins\.installs\..+\.(installedAt|resolvedAt)$/.test(path);
}
@@ -213,6 +215,8 @@ function getPluginInstallRecords(config: unknown): Record<string, unknown> {
if (!isPlainObject(plugins)) {
return {};
}
// Keep legacy config install records out of gateway restart decisions while
// migration/doctor moves them into the managed plugin install ledger.
const installs = plugins.installs;
return isPlainObject(installs) ? installs : {};
}

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import { writePersistedPluginInstallLedger } from "./install-ledger-store.js";
import {
diffInstalledPluginIndexInvalidationReasons,
getInstalledPluginRecord,
@@ -412,6 +413,42 @@ describe("installed plugin index", () => {
});
});
it("indexes persisted install ledger records from an explicit state directory", async () => {
const fixture = createRichPluginFixture();
const stateDir = makeTempDir();
await writePersistedPluginInstallLedger(
{
demo: {
source: "npm",
spec: "@vendor/demo-plugin@1.2.3",
installPath: fixture.rootDir,
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
integrity: "sha512-installed",
},
},
{ stateDir },
);
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
env: hermeticEnv(),
stateDir,
});
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
source: "npm",
spec: "@vendor/demo-plugin@1.2.3",
installPath: fixture.rootDir,
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
integrity: "sha512-installed",
},
});
});
it("indexes local fallback install ledger records written before a process reload", () => {
const fixture = createRichPluginFixture();
const cfg = recordPluginInstall(

View File

@@ -139,6 +139,8 @@ export type LoadInstalledPluginIndexParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
pluginInstallLedgerFilePath?: string;
cache?: boolean;
candidates?: PluginCandidate[];
diagnostics?: PluginDiagnostic[];
@@ -473,6 +475,8 @@ function buildInstalledPluginIndex(
const installRecords = loadPluginInstallRecordsSync({
config: params.config,
env: params.env,
stateDir: params.stateDir,
filePath: params.pluginInstallLedgerFilePath,
});
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
const candidate = candidateByRootDir.get(record.rootDir);

View File

@@ -431,10 +431,12 @@ export async function collectPluginsTrustFindings(params: {
.map(([pluginId, record]) => `${pluginId} (${record.spec})`);
if (unpinned.length > 0) {
findings.push({
// Keep the legacy checkId stable for downstream audit consumers while
// plugin install metadata moves from config to the managed ledger.
checkId: "plugins.installs_unpinned_npm_specs",
severity: "warn",
title: "Plugin installs include unpinned npm specs",
detail: `Unpinned plugin install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
title: "Plugin install ledger includes unpinned npm specs",
detail: `Unpinned plugin install ledger records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
});
@@ -449,8 +451,8 @@ export async function collectPluginsTrustFindings(params: {
findings.push({
checkId: "plugins.installs_missing_integrity",
severity: "warn",
title: "Plugin installs are missing integrity metadata",
detail: `Plugin install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
title: "Plugin install ledger is missing integrity metadata",
detail: `Plugin install ledger records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Reinstall or update plugins to refresh install metadata with resolved integrity hashes.",
});
@@ -475,7 +477,7 @@ export async function collectPluginsTrustFindings(params: {
findings.push({
checkId: "plugins.installs_version_drift",
severity: "warn",
title: "Plugin install records drift from installed package versions",
title: "Plugin install ledger records drift from installed package versions",
detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.",