mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-07 23:31:07 +00:00
feat(plugins): surface imported runtime state in status tooling (#59659)
* feat(plugins): surface imported runtime state * fix(plugins): keep status imports snapshot-only * fix(plugins): keep status snapshots manifest-only * fix(plugins): restore doctor load checks * refactor(plugins): split snapshot and diagnostics reports * fix(plugins): track imported erroring modules * fix(plugins): keep hot metadata where required * fix(plugins): keep hot doctor and write targeting * fix(plugins): track throwing module imports
This commit is contained in:
@@ -35,6 +35,7 @@ import { createEmptyPluginRegistry } from "./registry.js";
|
||||
import {
|
||||
getActivePluginRegistry,
|
||||
getActivePluginRegistryKey,
|
||||
listImportedRuntimePluginIds,
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "./runtime.js";
|
||||
@@ -1250,6 +1251,145 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
|
||||
expect(fs.existsSync(skippedMarker)).toBe(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "can build a manifest-only snapshot without importing plugin modules",
|
||||
run: () => {
|
||||
useNoBundledPlugins();
|
||||
const importedMarker = path.join(makeTempDir(), "manifest-only-imported.txt");
|
||||
const plugin = writePlugin({
|
||||
id: "manifest-only-plugin",
|
||||
filename: "manifest-only-plugin.cjs",
|
||||
body: `require("node:fs").writeFileSync(${JSON.stringify(importedMarker)}, "loaded", "utf-8");
|
||||
module.exports = { id: "manifest-only-plugin", register() { throw new Error("manifest-only snapshot should not register"); } };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
loadModules: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["manifest-only-plugin"],
|
||||
entries: {
|
||||
"manifest-only-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fs.existsSync(importedMarker)).toBe(false);
|
||||
expect(registry.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "manifest-only-plugin",
|
||||
status: "loaded",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "marks a selected memory slot as matched during manifest-only snapshots",
|
||||
run: () => {
|
||||
useNoBundledPlugins();
|
||||
const memoryPlugin = writePlugin({
|
||||
id: "memory-demo",
|
||||
filename: "memory-demo.cjs",
|
||||
body: `module.exports = {
|
||||
id: "memory-demo",
|
||||
kind: "memory",
|
||||
register() {},
|
||||
};`,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(memoryPlugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "memory-demo",
|
||||
kind: "memory",
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
loadModules: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [memoryPlugin.file] },
|
||||
allow: ["memory-demo"],
|
||||
slots: { memory: "memory-demo" },
|
||||
entries: {
|
||||
"memory-demo": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(entry) =>
|
||||
entry.message === "memory slot plugin not found or not marked as memory: memory-demo",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(registry.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "memory-demo",
|
||||
memorySlotSelected: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "tracks plugins as imported when module evaluation throws after top-level execution",
|
||||
run: () => {
|
||||
useNoBundledPlugins();
|
||||
const importMarker = "__openclaw_loader_import_throw_marker";
|
||||
Reflect.deleteProperty(globalThis, importMarker);
|
||||
|
||||
const plugin = writePlugin({
|
||||
id: "throws-after-import",
|
||||
filename: "throws-after-import.cjs",
|
||||
body: `globalThis.${importMarker} = (globalThis.${importMarker} ?? 0) + 1;
|
||||
throw new Error("boom after import");
|
||||
module.exports = { id: "throws-after-import", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["throws-after-import"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(registry.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "throws-after-import",
|
||||
status: "error",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(listImportedRuntimePluginIds()).toContain("throws-after-import");
|
||||
expect(Number(Reflect.get(globalThis, importMarker) ?? 0)).toBeGreaterThan(0);
|
||||
} finally {
|
||||
Reflect.deleteProperty(globalThis, importMarker);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "keeps scoped plugin loads in a separate cache entry",
|
||||
run: () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import {
|
||||
getActivePluginRegistry,
|
||||
getActivePluginRegistryKey,
|
||||
recordImportedPluginId,
|
||||
setActivePluginRegistry,
|
||||
} from "./runtime.js";
|
||||
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
|
||||
@@ -96,6 +97,7 @@ export type PluginLoadOptions = {
|
||||
*/
|
||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||
activate?: boolean;
|
||||
loadModules?: boolean;
|
||||
throwOnLoadError?: boolean;
|
||||
};
|
||||
|
||||
@@ -241,6 +243,7 @@ function buildCacheKey(params: {
|
||||
onlyPluginIds?: string[];
|
||||
includeSetupOnlyChannelPlugins?: boolean;
|
||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||
loadModules?: boolean;
|
||||
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
||||
pluginSdkResolution?: PluginSdkResolutionPreference;
|
||||
coreGatewayMethodNames?: string[];
|
||||
@@ -270,13 +273,14 @@ function buildCacheKey(params: {
|
||||
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
|
||||
const startupChannelMode =
|
||||
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
|
||||
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
|
||||
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||
...params.plugins,
|
||||
installs,
|
||||
loadPaths,
|
||||
activationMetadataKey: params.activationMetadataKey ?? "",
|
||||
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
|
||||
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
|
||||
}
|
||||
|
||||
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
|
||||
@@ -360,7 +364,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
|
||||
options.pluginSdkResolution !== undefined ||
|
||||
options.coreGatewayHandlers !== undefined ||
|
||||
options.includeSetupOnlyChannelPlugins === true ||
|
||||
options.preferSetupRuntimeForChannelPlugins === true,
|
||||
options.preferSetupRuntimeForChannelPlugins === true ||
|
||||
options.loadModules === false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,6 +392,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
||||
onlyPluginIds,
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
loadModules: options.loadModules,
|
||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||
pluginSdkResolution: options.pluginSdkResolution,
|
||||
coreGatewayMethodNames,
|
||||
@@ -402,6 +408,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
shouldActivate: options.activate !== false,
|
||||
shouldLoadModules: options.loadModules !== false,
|
||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||
cacheKey,
|
||||
};
|
||||
@@ -924,6 +931,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
shouldActivate,
|
||||
shouldLoadModules,
|
||||
cacheKey,
|
||||
runtimeSubagentMode,
|
||||
} = resolvePluginLoadCacheContext(options);
|
||||
@@ -1306,6 +1314,49 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldLoadModules && registrationMode === "full") {
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
});
|
||||
|
||||
if (!memoryDecision.enabled) {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = memoryDecision.reason;
|
||||
markPluginActivationDisabled(record, memoryDecision.reason);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
memorySlotMatched = true;
|
||||
record.memorySlotSelected = true;
|
||||
}
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
});
|
||||
|
||||
if (!validatedConfig.ok) {
|
||||
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldLoadModules) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||
const loadSource =
|
||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
@@ -1328,6 +1379,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
|
||||
let mod: OpenClawPluginModule | null = null;
|
||||
try {
|
||||
// Track the plugin as imported once module evaluation begins. Top-level
|
||||
// code may have already executed even if evaluation later throws.
|
||||
recordImportedPluginId(record.id);
|
||||
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
|
||||
} catch (err) {
|
||||
recordPluginError({
|
||||
@@ -1423,18 +1477,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
});
|
||||
|
||||
if (!validatedConfig.ok) {
|
||||
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (validateOnly) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
|
||||
@@ -200,6 +200,7 @@ export type PluginRecord = {
|
||||
enabled: boolean;
|
||||
explicitlyEnabled?: boolean;
|
||||
activated?: boolean;
|
||||
imported?: boolean;
|
||||
activationSource?: PluginActivationSource;
|
||||
activationReason?: string;
|
||||
status: "loaded" | "disabled" | "error";
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
getActivePluginHttpRouteRegistryVersion,
|
||||
getActivePluginRegistryVersion,
|
||||
getActivePluginRegistry,
|
||||
listImportedRuntimePluginIds,
|
||||
pinActivePluginHttpRouteRegistry,
|
||||
recordImportedPluginId,
|
||||
releasePinnedPluginHttpRouteRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
resolveActivePluginHttpRouteRegistry,
|
||||
@@ -180,6 +182,72 @@ describe("setActivePluginRegistry", () => {
|
||||
setActivePluginRegistry(registry);
|
||||
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not treat bundle-only loaded entries as imported runtime plugins", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push({
|
||||
id: "bundle-only",
|
||||
name: "Bundle Only",
|
||||
source: "/tmp/bundle",
|
||||
origin: "bundled",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
format: "bundle",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: true,
|
||||
});
|
||||
registry.plugins.push({
|
||||
id: "runtime-plugin",
|
||||
name: "Runtime Plugin",
|
||||
source: "/tmp/runtime",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
format: "openclaw",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(listImportedRuntimePluginIds()).toEqual(["runtime-plugin"]);
|
||||
});
|
||||
|
||||
it("includes plugin ids imported before registration failed", () => {
|
||||
recordImportedPluginId("broken-plugin");
|
||||
|
||||
expect(listImportedRuntimePluginIds()).toEqual(["broken-plugin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setActivePluginRegistry", () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ type RegistryState = {
|
||||
channel: RegistrySurfaceState;
|
||||
key: string | null;
|
||||
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable";
|
||||
importedPluginIds: Set<string>;
|
||||
};
|
||||
|
||||
const state: RegistryState = (() => {
|
||||
@@ -38,11 +39,16 @@ const state: RegistryState = (() => {
|
||||
},
|
||||
key: null,
|
||||
runtimeSubagentMode: "default",
|
||||
importedPluginIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
return globalState[REGISTRY_STATE];
|
||||
})();
|
||||
|
||||
export function recordImportedPluginId(pluginId: string): void {
|
||||
state.importedPluginIds.add(pluginId);
|
||||
}
|
||||
|
||||
function installSurfaceRegistry(
|
||||
surface: RegistrySurfaceState,
|
||||
registry: PluginRegistry | null,
|
||||
@@ -190,6 +196,39 @@ export function getActivePluginRegistryVersion(): number {
|
||||
return state.activeVersion;
|
||||
}
|
||||
|
||||
function collectLoadedPluginIds(
|
||||
registry: PluginRegistry | null | undefined,
|
||||
ids: Set<string>,
|
||||
): void {
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
for (const plugin of registry.plugins) {
|
||||
if (plugin.status === "loaded" && plugin.format !== "bundle") {
|
||||
ids.add(plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns plugin ids that were imported by plugin runtime or registry loading in
|
||||
* the current process.
|
||||
*
|
||||
* This is a process-level view, not a fresh import trace: cached registry reuse
|
||||
* still counts because the plugin code was loaded earlier in this process.
|
||||
* Explicit loader import tracking covers plugins that were imported but later
|
||||
* ended in an error state during registration.
|
||||
* Bundle-format plugins are excluded because they can be "loaded" from metadata
|
||||
* without importing any JS entrypoint.
|
||||
*/
|
||||
export function listImportedRuntimePluginIds(): string[] {
|
||||
const imported = new Set(state.importedPluginIds);
|
||||
collectLoadedPluginIds(state.activeRegistry, imported);
|
||||
collectLoadedPluginIds(state.channel.registry, imported);
|
||||
collectLoadedPluginIds(state.httpRoute.registry, imported);
|
||||
return [...imported].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function resetPluginRuntimeStateForTest(): void {
|
||||
state.activeRegistry = null;
|
||||
state.activeVersion += 1;
|
||||
@@ -197,4 +236,5 @@ export function resetPluginRuntimeStateForTest(): void {
|
||||
installSurfaceRegistry(state.channel, null, false);
|
||||
state.key = null;
|
||||
state.runtimeSubagentMode = "default";
|
||||
state.importedPluginIds.clear();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ const applyPluginAutoEnableMock = vi.fn();
|
||||
const resolveBundledProviderCompatPluginIdsMock = vi.fn();
|
||||
const withBundledPluginAllowlistCompatMock = vi.fn();
|
||||
const withBundledPluginEnablementCompatMock = vi.fn();
|
||||
const listImportedBundledPluginFacadeIdsMock = vi.fn();
|
||||
const listImportedRuntimePluginIdsMock = vi.fn();
|
||||
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
|
||||
let buildPluginSnapshotReport: typeof import("./status.js").buildPluginSnapshotReport;
|
||||
let buildPluginDiagnosticsReport: typeof import("./status.js").buildPluginDiagnosticsReport;
|
||||
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
|
||||
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
|
||||
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
|
||||
@@ -47,6 +51,15 @@ vi.mock("./bundled-compat.js", () => ({
|
||||
withBundledPluginEnablementCompatMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugin-sdk/facade-runtime.js", () => ({
|
||||
listImportedBundledPluginFacadeIds: (...args: unknown[]) =>
|
||||
listImportedBundledPluginFacadeIdsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
listImportedRuntimePluginIds: (...args: unknown[]) => listImportedRuntimePluginIdsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: () => undefined,
|
||||
resolveDefaultAgentId: () => "default",
|
||||
@@ -92,6 +105,7 @@ function expectPluginLoaderCall(params: {
|
||||
autoEnabledReasons?: Record<string, string[]>;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
loadModules?: boolean;
|
||||
}) {
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -104,6 +118,7 @@ function expectPluginLoaderCall(params: {
|
||||
: {}),
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -232,8 +247,10 @@ describe("buildPluginStatusReport", () => {
|
||||
({
|
||||
buildAllPluginInspectReports,
|
||||
buildPluginCompatibilityNotices,
|
||||
buildPluginDiagnosticsReport,
|
||||
buildPluginCompatibilityWarnings,
|
||||
buildPluginInspectReport,
|
||||
buildPluginSnapshotReport,
|
||||
buildPluginStatusReport,
|
||||
formatPluginCompatibilityNotice,
|
||||
summarizePluginCompatibility,
|
||||
@@ -247,6 +264,8 @@ describe("buildPluginStatusReport", () => {
|
||||
resolveBundledProviderCompatPluginIdsMock.mockReset();
|
||||
withBundledPluginAllowlistCompatMock.mockReset();
|
||||
withBundledPluginEnablementCompatMock.mockReset();
|
||||
listImportedBundledPluginFacadeIdsMock.mockReset();
|
||||
listImportedRuntimePluginIdsMock.mockReset();
|
||||
loadConfigMock.mockReturnValue({});
|
||||
applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({
|
||||
config: params.config,
|
||||
@@ -260,13 +279,15 @@ describe("buildPluginStatusReport", () => {
|
||||
withBundledPluginEnablementCompatMock.mockImplementation(
|
||||
(params: { config: unknown }) => params.config,
|
||||
);
|
||||
listImportedBundledPluginFacadeIdsMock.mockReturnValue([]);
|
||||
listImportedRuntimePluginIdsMock.mockReturnValue([]);
|
||||
setPluginLoadResult({ plugins: [] });
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
buildPluginStatusReport({
|
||||
buildPluginSnapshotReport({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
@@ -276,9 +297,22 @@ describe("buildPluginStatusReport", () => {
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
env,
|
||||
loadModules: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a non-activating snapshot load for snapshot reports", () => {
|
||||
buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" });
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activate: false,
|
||||
cache: false,
|
||||
loadModules: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads plugin status from the auto-enabled config snapshot", () => {
|
||||
const { rawConfig, autoEnabledConfig } = createAutoEnabledStatusConfig(
|
||||
{
|
||||
@@ -294,7 +328,7 @@ describe("buildPluginStatusReport", () => {
|
||||
},
|
||||
});
|
||||
|
||||
buildPluginStatusReport({ config: rawConfig });
|
||||
buildPluginSnapshotReport({ config: rawConfig });
|
||||
|
||||
expectAutoEnabledStatusLoad({
|
||||
rawConfig,
|
||||
@@ -303,6 +337,7 @@ describe("buildPluginStatusReport", () => {
|
||||
demo: ["demo configured"],
|
||||
},
|
||||
});
|
||||
expectPluginLoaderCall({ loadModules: false });
|
||||
});
|
||||
|
||||
it("uses the auto-enabled config snapshot for inspect policy summaries", () => {
|
||||
@@ -345,6 +380,7 @@ describe("buildPluginStatusReport", () => {
|
||||
allowedModels: ["openai/gpt-5.4"],
|
||||
hasAllowedModelsConfig: true,
|
||||
});
|
||||
expectPluginLoaderCall({ loadModules: true });
|
||||
});
|
||||
|
||||
it("preserves raw config activation context when compatibility notices build their own report", () => {
|
||||
@@ -386,6 +422,7 @@ describe("buildPluginStatusReport", () => {
|
||||
demo: ["demo configured"],
|
||||
},
|
||||
});
|
||||
expectPluginLoaderCall({ loadModules: true });
|
||||
});
|
||||
|
||||
it("applies the full bundled provider compat chain before loading plugins", () => {
|
||||
@@ -395,7 +432,7 @@ describe("buildPluginStatusReport", () => {
|
||||
withBundledPluginAllowlistCompatMock.mockReturnValue(compatConfig);
|
||||
withBundledPluginEnablementCompatMock.mockReturnValue(enabledConfig);
|
||||
|
||||
buildPluginStatusReport({ config });
|
||||
buildPluginSnapshotReport({ config });
|
||||
|
||||
expectBundledCompatChainApplied({
|
||||
config,
|
||||
@@ -427,6 +464,63 @@ describe("buildPluginStatusReport", () => {
|
||||
expect(report.plugins[0]?.version).toBe("2026.3.23");
|
||||
});
|
||||
|
||||
it("marks plugins as imported when runtime or facade state has loaded them", () => {
|
||||
setPluginLoadResult({
|
||||
plugins: [
|
||||
createPluginRecord({ id: "runtime-loaded" }),
|
||||
createPluginRecord({ id: "facade-loaded" }),
|
||||
createPluginRecord({ id: "bundle-loaded", format: "bundle" }),
|
||||
createPluginRecord({ id: "cold-plugin" }),
|
||||
],
|
||||
});
|
||||
listImportedRuntimePluginIdsMock.mockReturnValue(["runtime-loaded", "bundle-loaded"]);
|
||||
listImportedBundledPluginFacadeIdsMock.mockReturnValue(["facade-loaded"]);
|
||||
|
||||
const report = buildPluginSnapshotReport({ config: {} });
|
||||
|
||||
expect(report.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "runtime-loaded", imported: true }),
|
||||
expect.objectContaining({ id: "facade-loaded", imported: true }),
|
||||
expect.objectContaining({ id: "bundle-loaded", imported: false }),
|
||||
expect.objectContaining({ id: "cold-plugin", imported: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks snapshot-loaded plugin modules as imported during full report loads", () => {
|
||||
setPluginLoadResult({
|
||||
plugins: [
|
||||
createPluginRecord({ id: "runtime-loaded" }),
|
||||
createPluginRecord({ id: "bundle-loaded", format: "bundle" }),
|
||||
],
|
||||
});
|
||||
|
||||
const report = buildPluginDiagnosticsReport({ config: {} });
|
||||
|
||||
expect(report.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "runtime-loaded", imported: true }),
|
||||
expect.objectContaining({ id: "bundle-loaded", imported: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks errored plugin modules as imported when full diagnostics already evaluated them", () => {
|
||||
setPluginLoadResult({
|
||||
plugins: [createPluginRecord({ id: "broken-plugin", status: "error" })],
|
||||
});
|
||||
listImportedRuntimePluginIdsMock.mockReturnValue(["broken-plugin"]);
|
||||
|
||||
const report = buildPluginDiagnosticsReport({ config: {} });
|
||||
|
||||
expect(report.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "broken-plugin", status: "error", imported: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an inspect report with capability shape and policy", () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
plugins: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { normalizeOpenClawVersionBase } from "../config/version.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { listImportedBundledPluginFacadeIds } from "../plugin-sdk/facade-runtime.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
|
||||
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
||||
@@ -16,6 +17,7 @@ import { loadOpenClawPlugins } from "./loader.js";
|
||||
import { createPluginLoaderLogger } from "./logger.js";
|
||||
import { resolveBundledProviderCompatPluginIds } from "./providers.js";
|
||||
import type { PluginRegistry } from "./registry.js";
|
||||
import { listImportedRuntimePluginIds } from "./runtime.js";
|
||||
import type { PluginDiagnostic, PluginHookName } from "./types.js";
|
||||
|
||||
export type PluginStatusReport = PluginRegistry & {
|
||||
@@ -147,12 +149,17 @@ function resolveReportedPluginVersion(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPluginStatusReport(params?: {
|
||||
type PluginReportParams = {
|
||||
config?: ReturnType<typeof loadConfig>;
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginStatusReport {
|
||||
};
|
||||
|
||||
function buildPluginReport(
|
||||
params: PluginReportParams | undefined,
|
||||
loadModules: boolean,
|
||||
): PluginStatusReport {
|
||||
const rawConfig = params?.config ?? loadConfig();
|
||||
const autoEnabled = resolveStatusConfig(rawConfig, params?.env);
|
||||
const config = autoEnabled.config;
|
||||
@@ -188,18 +195,45 @@ export function buildPluginStatusReport(params?: {
|
||||
workspaceDir,
|
||||
env: params?.env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
activate: false,
|
||||
cache: false,
|
||||
loadModules,
|
||||
});
|
||||
const importedPluginIds = new Set([
|
||||
...(loadModules
|
||||
? registry.plugins
|
||||
.filter((plugin) => plugin.status === "loaded" && plugin.format !== "bundle")
|
||||
.map((plugin) => plugin.id)
|
||||
: []),
|
||||
...listImportedRuntimePluginIds(),
|
||||
...listImportedBundledPluginFacadeIds(),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceDir,
|
||||
...registry,
|
||||
plugins: registry.plugins.map((plugin) => ({
|
||||
...plugin,
|
||||
imported: plugin.format !== "bundle" && importedPluginIds.has(plugin.id),
|
||||
version: resolveReportedPluginVersion(plugin, params?.env),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginSnapshotReport(params?: PluginReportParams): PluginStatusReport {
|
||||
return buildPluginReport(params, false);
|
||||
}
|
||||
|
||||
export function buildPluginDiagnosticsReport(params?: PluginReportParams): PluginStatusReport {
|
||||
return buildPluginReport(params, true);
|
||||
}
|
||||
|
||||
// Compatibility alias for existing hot/reporting callers while the repo finishes
|
||||
// migrating to explicit snapshot vs diagnostics builders.
|
||||
export function buildPluginStatusReport(params?: PluginReportParams): PluginStatusReport {
|
||||
return buildPluginDiagnosticsReport(params);
|
||||
}
|
||||
|
||||
function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) {
|
||||
return [
|
||||
{ kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] },
|
||||
@@ -255,7 +289,7 @@ export function buildPluginInspectReport(params: {
|
||||
const config = resolvedConfig.config;
|
||||
const report =
|
||||
params.report ??
|
||||
buildPluginStatusReport({
|
||||
buildPluginDiagnosticsReport({
|
||||
config: rawConfig,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
@@ -388,7 +422,7 @@ export function buildAllPluginInspectReports(params?: {
|
||||
const rawConfig = params?.config ?? loadConfig();
|
||||
const report =
|
||||
params?.report ??
|
||||
buildPluginStatusReport({
|
||||
buildPluginDiagnosticsReport({
|
||||
config: rawConfig,
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env: params?.env,
|
||||
|
||||
Reference in New Issue
Block a user