From 793b58b3f11bbbd06f352f970749f8b7a65b0996 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 12:15:40 -0700 Subject: [PATCH] fix(plugins): add doctor registry repair --- CHANGELOG.md | 3 + docs/plugins/architecture-internals.md | 3 + docs/tools/plugin.md | 6 +- src/commands/doctor-plugin-registry.test.ts | 149 +++++++++++++++++ src/commands/doctor-plugin-registry.ts | 158 ++++++++++++++++++ src/flows/doctor-health-contributions.test.ts | 7 + src/flows/doctor-health-contributions.ts | 14 ++ src/gateway/config-reload-plan.ts | 4 + src/plugins/installed-plugin-index.test.ts | 37 ++++ src/plugins/installed-plugin-index.ts | 4 + src/security/audit-plugins-trust.ts | 12 +- 11 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 src/commands/doctor-plugin-registry.test.ts create mode 100644 src/commands/doctor-plugin-registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75eaac7e13e..3c97b5c3a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 203e8a9d960..98215dc3206 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 1d4175e5d11..ca336e630da 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -266,6 +266,7 @@ openclaw plugins info # 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 # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only @@ -279,7 +280,7 @@ openclaw plugins install --dangerously-force-unsafe-install openclaw plugins update # update one plugin openclaw plugins update --dangerously-force-unsafe-install openclaw plugins update --all # update all -openclaw plugins uninstall # remove config/install records +openclaw plugins uninstall # remove config and install ledger records openclaw plugins uninstall --keep-files openclaw plugins marketplace list openclaw plugins marketplace list --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 ` applies to tracked installs. Passing an npm package spec with a dist-tag or exact version resolves the package name diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts new file mode 100644 index 00000000000..3dc4022c913 --- /dev/null +++ b/src/commands/doctor-plugin-registry.test.ts @@ -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", + }), + }), + ], + }); + }); +}); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts new file mode 100644 index 00000000000..563877f12b4 --- /dev/null +++ b/src/commands/doctor-plugin-registry.ts @@ -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 & + PluginInstallLedgerStoreOptions & { + config: OpenClawConfig; + prompter: Pick; + }; + +type LegacyInstallLedgerMigrationResult = { + config: OpenClawConfig; + migrated: boolean; + recordCount: number; +}; + +function countRecords(records: Record | undefined): number { + return Object.keys(records ?? {}).length; +} + +function mergeInstallRecords( + legacyRecords: Record, + ledgerRecords: Record | undefined, +): Record { + return { + ...legacyRecords, + ...(ledgerRecords ?? {}), + }; +} + +async function maybeMigrateLegacyInstallLedger( + params: PluginRegistryDoctorRepairParams, +): Promise { + 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 { + 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; +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 2a0ec743a4a..a81015ec912 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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")); + }); }); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 34e5ab56246..570725303d6 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -223,6 +223,15 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom }); } +async function runPluginRegistryHealth(ctx: DoctorHealthFlowContext): Promise { + 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 { 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", diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 20e4367acc4..79ce46b2ed0 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -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 { 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 : {}; } diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 99bfac77b47..6c30e914325 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -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( diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5a9a9224adb..f437d538cc4 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -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); diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index 0d5c55a8271..ab22d25e322 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -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.",