From 74b3e4dde696c0511dac77e020c2f5a057e16618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E8=92=8B?= <23394662@qq.com> Date: Tue, 16 Jun 2026 23:46:50 +0800 Subject: [PATCH 1/4] fix(plugins): use orphan-source-path code to detect stale diagnostics Replace the blanket !pluginId -> stale rule with a targeted orphan-source-path diagnostic code. discovery.ts now sets this code on diagnostics from discoverFromPath, and hasStalePersistedPluginDiagnostics checks the code instead of requiring pluginId to be absent. This avoids treating legitimate diagnostics without pluginId (e.g. package plugin validation warnings, source-overlay diagnostics) as stale, while still correctly invalidating the persisted registry when a configured load path disappears. Fixes: Gateway startup failure when stale plugin diagnostics_json persists after plugin uninstall. --- src/plugins/discovery.ts | 2 + src/plugins/manifest-types.ts | 2 +- src/plugins/plugin-registry-snapshot.test.ts | 51 ++++++++++++++++++-- src/plugins/plugin-registry-snapshot.ts | 24 +++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 546d77ac03fe9..8e00a5cffcc4b 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/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..4f35869711319 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -935,12 +935,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 +948,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { level: "error", message: `plugin path not found: ${missingConfiguredPath}`, source: missingConfiguredPath, + code: "orphan-source-path", }, ], }; @@ -955,8 +956,48 @@ 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"); }); }); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index f3e92aa02c0b0..d0298da932af5 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -414,13 +414,27 @@ 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; + } + return false; }); } From 79ea25d5afac8ff5ea463c60abd08ab6e7de8bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E8=92=8B?= <23394662@qq.com> Date: Wed, 17 Jun 2026 10:45:51 +0800 Subject: [PATCH 2/4] fix(plugins): add code field to PluginDiagnosticSchema for round-trip persistence The PluginDiagnosticSchema Zod schema was missing the 'code' field that discovery.ts now sets on orphan-source-path diagnostics. This caused the code field to be stripped during SQLite read-back via safeParseWithSchema, preventing hasStalePersistedPluginDiagnostics from detecting stale orphan diagnostics in the persisted plugin index. After this fix, diagnostics with code='orphan-source-path' survive the write/read round-trip through the installed plugin index SQLite store, and the snapshot loading fast path correctly invalidates the persisted registry when orphan diagnostics are present. --- src/plugins/installed-plugin-index-store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 7aaa2cdf2eaa8..420509ddfc2f7 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({ From 5e71b536963b457f0d89917569168d717b3d5b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E8=92=8B?= <23394662@qq.com> Date: Wed, 17 Jun 2026 14:39:53 +0800 Subject: [PATCH 3/4] fix(plugins): remove unused expectDiagnosticsContainSource test helper --- src/plugins/plugin-registry-snapshot.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 4f35869711319..fbafb7a0eef0e 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, From d8accfce55ccd7b1aed032d1ce639698a7ade8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E8=92=8B?= <23394662@qq.com> Date: Thu, 18 Jun 2026 17:22:06 +0800 Subject: [PATCH 4/4] fix(plugins): handle legacy no-code orphan diagnostics on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClawSweeper P1: existing users upgrading from released/current OpenClaw have SQLite diagnostics_json rows without the .code field (added in PluginDiagnosticSchema as part of this PR). Those rows have no pluginId and no code but an absolute source that doesn't exist — they are conceptually orphan diagnostics that must trigger a registry refresh instead of being silently reused (which blocks Gateway startup). Add a legacy path in hasStalePersistedPluginDiagnostics: when a diagnostic has no pluginId, a missing absolute source, and no code field (undefined), treat it as stale. This is a narrow, explicit check that avoids the blanket !pluginId rule rejected by maintainers on the original #93467. New test: 'refreshes registry for legacy no-code orphan diagnostics (upgrade from pre-code schema)' — simulates SQLite row exactly as current/released OpenClaw would have written it. --- src/plugins/plugin-registry-snapshot.test.ts | 34 ++++++++++++++++++++ src/plugins/plugin-registry-snapshot.ts | 9 ++++++ 2 files changed, 43 insertions(+) diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index fbafb7a0eef0e..c095464f0268f 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -993,4 +993,38 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { 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 d0298da932af5..a522f3df014a9 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -434,6 +434,15 @@ function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolea 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; }); }