diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 547f6777ac312..49923d54f979a 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1226,6 +1226,7 @@ function discoverFromPath(params: { level: "error", message: `plugin path not found: ${resolved}`, source: resolved, + code: "orphan-source-path", }); return; } @@ -1237,6 +1238,7 @@ function discoverFromPath(params: { level: "error", message: `plugin path is not a supported file: ${resolved}`, source: resolved, + code: "orphan-source-path", }); return; } diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 839875cad4508..25e5668b1dbdd 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -120,6 +120,7 @@ const PluginDiagnosticSchema = z.object({ message: z.string(), pluginId: z.string().optional(), source: z.string().optional(), + code: z.string().optional(), }); const InstalledPluginIndexSchema = z.object({ diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 272ceb16fcf74..3ddedc3200fbb 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -18,7 +18,7 @@ export type PluginBundleFormat = "codex" | "claude" | "cursor"; * Closed classification codes for plugin diagnostics. Health surfaces branch * on these instead of matching freeform diagnostic message text. */ -export type PluginDiagnosticCode = "channel-setup-failure"; +export type PluginDiagnosticCode = "channel-setup-failure" | "orphan-source-path"; /** Diagnostic emitted while discovering or validating plugins. */ export type PluginDiagnostic = { diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 23d94b134bf12..c095464f0268f 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -165,13 +165,6 @@ function expectDiagnosticsContainCode(diagnostics: readonly { code?: unknown }[] expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); } -function expectDiagnosticsContainSource( - diagnostics: readonly { source?: unknown }[], - source: string, -) { - expect(diagnostics.map((diagnostic) => diagnostic.source)).toContain(source); -} - function expectDiagnosticsDoNotContainSource( diagnostics: readonly { source?: unknown }[], source: string, @@ -935,12 +928,12 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); }); - it("keeps persisted registry when a non-plugin diagnostic source path still does not exist", () => { + it("refreshes registry when an orphan diagnostic without pluginId exists", () => { const tempRoot = makeTempDir(); const stateDir = path.join(tempRoot, "state"); const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; const config = {}; - const missingConfiguredPath = path.join(tempRoot, "missing-configured-plugin"); + const missingConfiguredPath = path.join(tempRoot, "missing-configured-path.js"); const index: InstalledPluginIndex = { ...loadInstalledPluginIndex({ config, env, stateDir, installRecords: {} }), diagnostics: [ @@ -948,6 +941,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { level: "error", message: `plugin path not found: ${missingConfiguredPath}`, source: missingConfiguredPath, + code: "orphan-source-path", }, ], }; @@ -955,8 +949,82 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); - expect(result.source).toBe("persisted"); - expectDiagnosticsContainSource(result.snapshot.diagnostics, missingConfiguredPath); - expect(result.diagnostics).toStrictEqual([]); + // Diagnostics tagged with orphan-source-path are always stale — the + // registry is refreshed so the current file system state is reflected. + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + }); + + it("refreshes registry for orphan diagnostics even when the source path still does not exist", () => { + // This test documents a design choice: orphan diagnostics tagged with + // orphan-source-path (set by discoverFromPath when a configured load path + // doesn't exist) are always stale, even when the source path referenced by + // the diagnostic still doesn't exist. This means users with persistent + // config issues (e.g. a plugin path that genuinely doesn't exist) will + // experience a full rediscovery on every Gateway startup until they fix + // the config. This trade-off is acceptable because: + // 1. The rediscovery path is bounded and self-correcting. + // 2. It gives users an up-to-date view of their plugin system. + // 3. The fast path is a performance optimization, not a correctness requirement. + const tempRoot = makeTempDir(); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = {}; + const missingConfiguredPath = path.join(tempRoot, "missing-configured-path.js"); + const index: InstalledPluginIndex = { + ...loadInstalledPluginIndex({ config, env, stateDir, installRecords: {} }), + diagnostics: [ + { + level: "error", + message: `plugin path not found: ${missingConfiguredPath}`, + source: missingConfiguredPath, + code: "orphan-source-path", + }, + ], + }; + writePersistedInstalledPluginIndexSync(index, { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + // Even though the source path still doesn't exist (so the diagnostic is + // still factually accurate), the orphan-source-path tag makes the + // diagnostic always stale. The registry is refreshed to produce a current + // snapshot. + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + }); + + it("refreshes registry for legacy no-code orphan diagnostics (upgrade from pre-code schema)", () => { + // Simulates an upgrade from current/released OpenClaw where SQLite + // diagnostics_json rows were written before PluginDiagnosticSchema gained + // the `.code` field. These rows have no pluginId and no code but an + // absolute source that doesn't exist — they are conceptually orphan + // diagnostics that should trigger a registry refresh instead of being + // silently reused (which would block Gateway startup). + const tempRoot = makeTempDir(); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = {}; + const missingConfiguredPath = path.join(tempRoot, "missing-configured-path.js"); + const index: InstalledPluginIndex = { + ...loadInstalledPluginIndex({ config, env, stateDir, installRecords: {} }), + diagnostics: [ + { + level: "error", + message: `plugin path not found: ${missingConfiguredPath}`, + source: missingConfiguredPath, + // Legacy row: no code field, no pluginId — exactly what current/released + // OpenClaw wrote before the code field was added. + }, + ], + }; + writePersistedInstalledPluginIndexSync(index, { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + // Legacy no-code orphan diagnostics with a missing source should also + // cause a re-derivation so the registry reflects the current state. + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); }); }); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index f3e92aa02c0b0..a522f3df014a9 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -414,13 +414,36 @@ function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): strin function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean { return index.diagnostics.some((diag) => { const source = diag.source; - return ( - typeof diag.pluginId === "string" && - diag.pluginId.trim().length > 0 && + const hasPluginId = + typeof diag.pluginId === "string" && diag.pluginId.trim().length > 0; + const sourceMissing = typeof source === "string" && path.isAbsolute(source) && - !fs.existsSync(source) - ); + !fs.existsSync(source); + // Diagnostics tied to a specific plugin become stale when the source path + // no longer exists (e.g. the plugin was uninstalled or moved). + if (hasPluginId && sourceMissing) { + return true; + } + // Diagnostics tagged with the orphan-source-path code (set by + // discoverFromPath when a configured load path does not exist or is + // not a supported file) are always stale. Without this, removing a + // plugin and its files would leave a "plugin path not found" diagnostic + // that persists forever in SQLite diagnostics_json, blocking Gateway + // startup with an ok:false config validation error. + if (diag.code === "orphan-source-path") { + return true; + } + // Legacy upgrade path: diagnostics persisted before the.code field was + // added to PluginDiagnosticSchema (released or current OpenClaw). Those + // rows have no pluginId and no code, but are conceptually orphan + // diagnostics if their absolute source path no longer exists. Without + // this, users upgrading from an older version with stale SQLite + // diagnostics_json rows would still hit a Gateway startup block. + if (!hasPluginId && sourceMissing && diag.code === undefined) { + return true; + } + return false; }); }