Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/plugins/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ function discoverFromPath(params: {
level: "error",
message: `plugin path not found: ${resolved}`,
source: resolved,
code: "orphan-source-path",
});
return;
}
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/plugins/installed-plugin-index-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/manifest-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
92 changes: 80 additions & 12 deletions src/plugins/plugin-registry-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -935,28 +928,103 @@ 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: [
{
level: "error",
message: `plugin path not found: ${missingConfiguredPath}`,
source: missingConfiguredPath,
code: "orphan-source-path",
},
],
};
writePersistedInstalledPluginIndexSync(index, { stateDir });

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");
});
});
33 changes: 28 additions & 5 deletions src/plugins/plugin-registry-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}

Expand Down
Loading