mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 16:33:39 +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:
@@ -8,12 +8,14 @@ const {
|
|||||||
readConfigFileSnapshotMock,
|
readConfigFileSnapshotMock,
|
||||||
validateConfigObjectWithPluginsMock,
|
validateConfigObjectWithPluginsMock,
|
||||||
writeConfigFileMock,
|
writeConfigFileMock,
|
||||||
buildPluginStatusReportMock,
|
buildPluginSnapshotReportMock,
|
||||||
|
buildPluginDiagnosticsReportMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
readConfigFileSnapshotMock: vi.fn(),
|
readConfigFileSnapshotMock: vi.fn(),
|
||||||
validateConfigObjectWithPluginsMock: vi.fn(),
|
validateConfigObjectWithPluginsMock: vi.fn(),
|
||||||
writeConfigFileMock: vi.fn(),
|
writeConfigFileMock: vi.fn(),
|
||||||
buildPluginStatusReportMock: vi.fn(),
|
buildPluginSnapshotReportMock: vi.fn(),
|
||||||
|
buildPluginDiagnosticsReportMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/config.js", async () => {
|
vi.mock("../../config/config.js", async () => {
|
||||||
@@ -32,7 +34,8 @@ vi.mock("../../plugins/status.js", async () => {
|
|||||||
await vi.importActual<typeof import("../../plugins/status.js")>("../../plugins/status.js");
|
await vi.importActual<typeof import("../../plugins/status.js")>("../../plugins/status.js");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
buildPluginStatusReport: buildPluginStatusReportMock,
|
buildPluginSnapshotReport: buildPluginSnapshotReportMock,
|
||||||
|
buildPluginDiagnosticsReport: buildPluginDiagnosticsReportMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +59,8 @@ describe("handleCommands /plugins toggle", () => {
|
|||||||
readConfigFileSnapshotMock.mockReset();
|
readConfigFileSnapshotMock.mockReset();
|
||||||
validateConfigObjectWithPluginsMock.mockReset();
|
validateConfigObjectWithPluginsMock.mockReset();
|
||||||
writeConfigFileMock.mockReset();
|
writeConfigFileMock.mockReset();
|
||||||
buildPluginStatusReportMock.mockReset();
|
buildPluginSnapshotReportMock.mockReset();
|
||||||
|
buildPluginDiagnosticsReportMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables a discovered plugin", async () => {
|
it("enables a discovered plugin", async () => {
|
||||||
@@ -66,7 +70,7 @@ describe("handleCommands /plugins toggle", () => {
|
|||||||
path: "/tmp/openclaw.json",
|
path: "/tmp/openclaw.json",
|
||||||
resolved: config,
|
resolved: config,
|
||||||
});
|
});
|
||||||
buildPluginStatusReportMock.mockReturnValue(
|
buildPluginDiagnosticsReportMock.mockReturnValue(
|
||||||
createPluginStatusReport({
|
createPluginStatusReport({
|
||||||
workspaceDir: "/tmp/workspace",
|
workspaceDir: "/tmp/workspace",
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -106,7 +110,7 @@ describe("handleCommands /plugins toggle", () => {
|
|||||||
path: "/tmp/openclaw.json",
|
path: "/tmp/openclaw.json",
|
||||||
resolved: config,
|
resolved: config,
|
||||||
});
|
});
|
||||||
buildPluginStatusReportMock.mockReturnValue(
|
buildPluginDiagnosticsReportMock.mockReturnValue(
|
||||||
createPluginStatusReport({
|
createPluginStatusReport({
|
||||||
workspaceDir: "/tmp/workspace",
|
workspaceDir: "/tmp/workspace",
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -137,4 +141,53 @@ describe("handleCommands /plugins toggle", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves write targets by runtime-derived plugin name", async () => {
|
||||||
|
const config = buildCfg();
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
path: "/tmp/openclaw.json",
|
||||||
|
resolved: config,
|
||||||
|
});
|
||||||
|
buildPluginSnapshotReportMock.mockReturnValue(
|
||||||
|
createPluginStatusReport({
|
||||||
|
workspaceDir: "/tmp/workspace",
|
||||||
|
plugins: [
|
||||||
|
createPluginRecord({
|
||||||
|
id: "superpowers",
|
||||||
|
name: "superpowers",
|
||||||
|
format: "bundle",
|
||||||
|
source: WORKSPACE_PLUGIN_ROOT,
|
||||||
|
enabled: false,
|
||||||
|
status: "disabled",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
buildPluginDiagnosticsReportMock.mockReturnValue(
|
||||||
|
createPluginStatusReport({
|
||||||
|
workspaceDir: "/tmp/workspace",
|
||||||
|
plugins: [
|
||||||
|
createPluginRecord({
|
||||||
|
id: "superpowers",
|
||||||
|
name: "Super Powers",
|
||||||
|
format: "bundle",
|
||||||
|
source: WORKSPACE_PLUGIN_ROOT,
|
||||||
|
enabled: false,
|
||||||
|
status: "disabled",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
|
||||||
|
writeConfigFileMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const params = buildCommandTestParams("/plugins enable Super Powers", buildCfg());
|
||||||
|
params.command.senderIsOwner = true;
|
||||||
|
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.reply?.text).toContain('Plugin "superpowers" enabled');
|
||||||
|
expect(buildPluginDiagnosticsReportMock).toHaveBeenCalled();
|
||||||
|
expect(buildPluginSnapshotReportMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registr
|
|||||||
import type { PluginRecord } from "../../plugins/registry.js";
|
import type { PluginRecord } from "../../plugins/registry.js";
|
||||||
import {
|
import {
|
||||||
buildAllPluginInspectReports,
|
buildAllPluginInspectReports,
|
||||||
|
buildPluginDiagnosticsReport,
|
||||||
buildPluginInspectReport,
|
buildPluginInspectReport,
|
||||||
buildPluginStatusReport,
|
buildPluginSnapshotReport,
|
||||||
formatPluginCompatibilityNotice,
|
formatPluginCompatibilityNotice,
|
||||||
type PluginStatusReport,
|
type PluginStatusReport,
|
||||||
} from "../../plugins/status.js";
|
} from "../../plugins/status.js";
|
||||||
@@ -272,7 +273,10 @@ async function installPluginFromPluginsCommand(params: {
|
|||||||
return { ok: true, pluginId: result.pluginId };
|
return { ok: true, pluginId: result.pluginId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPluginCommandState(workspaceDir: string): Promise<
|
async function loadPluginCommandState(
|
||||||
|
workspaceDir: string,
|
||||||
|
options?: { loadModules?: boolean },
|
||||||
|
): Promise<
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -294,7 +298,10 @@ async function loadPluginCommandState(workspaceDir: string): Promise<
|
|||||||
ok: true,
|
ok: true,
|
||||||
path: snapshot.path,
|
path: snapshot.path,
|
||||||
config,
|
config,
|
||||||
report: buildPluginStatusReport({ config, workspaceDir }),
|
report:
|
||||||
|
options?.loadModules === true
|
||||||
|
? buildPluginDiagnosticsReport({ config, workspaceDir })
|
||||||
|
: buildPluginSnapshotReport({ config, workspaceDir }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +338,9 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await loadPluginCommandState(params.workspaceDir);
|
const loaded = await loadPluginCommandState(params.workspaceDir, {
|
||||||
|
loadModules: pluginsCommand.action !== "list",
|
||||||
|
});
|
||||||
if (!loaded.ok) {
|
if (!loaded.ok) {
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { resolveHookEntries } from "../hooks/policy.js";
|
import { resolveHookEntries } from "../hooks/policy.js";
|
||||||
import type { HookEntry } from "../hooks/types.js";
|
import type { HookEntry } from "../hooks/types.js";
|
||||||
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
|
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
|
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
|
||||||
@@ -46,7 +46,7 @@ function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntr
|
|||||||
function buildHooksReport(config: OpenClawConfig): HookStatusReport {
|
function buildHooksReport(config: OpenClawConfig): HookStatusReport {
|
||||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||||
const workspaceEntries = loadWorkspaceHookEntries(workspaceDir, { config });
|
const workspaceEntries = loadWorkspaceHookEntries(workspaceDir, { config });
|
||||||
const pluginReport = buildPluginStatusReport({ config, workspaceDir });
|
const pluginReport = buildPluginDiagnosticsReport({ config, workspaceDir });
|
||||||
const pluginEntries = pluginReport.hooks.map((hook) => hook.entry);
|
const pluginEntries = pluginReport.hooks.map((hook) => hook.entry);
|
||||||
const entries = mergeHookEntries(pluginEntries, workspaceEntries);
|
const entries = mergeHookEntries(pluginEntries, workspaceEntries);
|
||||||
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
|
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export const resolveMarketplaceInstallShortcut = vi.fn();
|
|||||||
export const enablePluginInConfig = vi.fn();
|
export const enablePluginInConfig = vi.fn();
|
||||||
export const recordPluginInstall = vi.fn();
|
export const recordPluginInstall = vi.fn();
|
||||||
export const clearPluginManifestRegistryCache = vi.fn();
|
export const clearPluginManifestRegistryCache = vi.fn();
|
||||||
export const buildPluginStatusReport = vi.fn();
|
export const buildPluginSnapshotReport = vi.fn();
|
||||||
|
export const buildPluginDiagnosticsReport = vi.fn();
|
||||||
|
export const buildPluginStatusReport = buildPluginDiagnosticsReport;
|
||||||
|
export const buildPluginCompatibilityNotices = vi.fn();
|
||||||
export const applyExclusiveSlotSelection = vi.fn();
|
export const applyExclusiveSlotSelection = vi.fn();
|
||||||
export const uninstallPlugin = vi.fn();
|
export const uninstallPlugin = vi.fn();
|
||||||
export const updateNpmInstalledPlugins = vi.fn();
|
export const updateNpmInstalledPlugins = vi.fn();
|
||||||
@@ -72,7 +75,9 @@ vi.mock("../plugins/manifest-registry.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/status.js", () => ({
|
vi.mock("../plugins/status.js", () => ({
|
||||||
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
|
buildPluginSnapshotReport: (...args: unknown[]) => buildPluginSnapshotReport(...args),
|
||||||
|
buildPluginDiagnosticsReport: (...args: unknown[]) => buildPluginDiagnosticsReport(...args),
|
||||||
|
buildPluginCompatibilityNotices: (...args: unknown[]) => buildPluginCompatibilityNotices(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/slots.js", () => ({
|
vi.mock("../plugins/slots.js", () => ({
|
||||||
@@ -154,7 +159,9 @@ export function resetPluginsCliTestState() {
|
|||||||
enablePluginInConfig.mockReset();
|
enablePluginInConfig.mockReset();
|
||||||
recordPluginInstall.mockReset();
|
recordPluginInstall.mockReset();
|
||||||
clearPluginManifestRegistryCache.mockReset();
|
clearPluginManifestRegistryCache.mockReset();
|
||||||
buildPluginStatusReport.mockReset();
|
buildPluginSnapshotReport.mockReset();
|
||||||
|
buildPluginDiagnosticsReport.mockReset();
|
||||||
|
buildPluginCompatibilityNotices.mockReset();
|
||||||
applyExclusiveSlotSelection.mockReset();
|
applyExclusiveSlotSelection.mockReset();
|
||||||
uninstallPlugin.mockReset();
|
uninstallPlugin.mockReset();
|
||||||
updateNpmInstalledPlugins.mockReset();
|
updateNpmInstalledPlugins.mockReset();
|
||||||
@@ -199,10 +206,13 @@ export function resetPluginsCliTestState() {
|
|||||||
});
|
});
|
||||||
enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg }));
|
enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg }));
|
||||||
recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
||||||
buildPluginStatusReport.mockReturnValue({
|
const defaultPluginReport = {
|
||||||
plugins: [],
|
plugins: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
};
|
||||||
|
buildPluginSnapshotReport.mockReturnValue(defaultPluginReport);
|
||||||
|
buildPluginDiagnosticsReport.mockReturnValue(defaultPluginReport);
|
||||||
|
buildPluginCompatibilityNotices.mockReturnValue([]);
|
||||||
applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({
|
applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({
|
||||||
config,
|
config,
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
|||||||
83
src/cli/plugins-cli.list.test.ts
Normal file
83
src/cli/plugins-cli.list.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createPluginRecord } from "../plugins/status.test-helpers.js";
|
||||||
|
import {
|
||||||
|
buildPluginDiagnosticsReport,
|
||||||
|
buildPluginSnapshotReport,
|
||||||
|
resetPluginsCliTestState,
|
||||||
|
runPluginsCommand,
|
||||||
|
runtimeLogs,
|
||||||
|
} from "./plugins-cli-test-helpers.js";
|
||||||
|
|
||||||
|
describe("plugins cli list", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetPluginsCliTestState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes imported state in JSON output", async () => {
|
||||||
|
buildPluginSnapshotReport.mockReturnValue({
|
||||||
|
workspaceDir: "/workspace",
|
||||||
|
plugins: [
|
||||||
|
createPluginRecord({
|
||||||
|
id: "demo",
|
||||||
|
imported: true,
|
||||||
|
activated: true,
|
||||||
|
explicitlyEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPluginsCommand(["plugins", "list", "--json"]);
|
||||||
|
|
||||||
|
expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({
|
||||||
|
workspaceDir: "/workspace",
|
||||||
|
plugins: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "demo",
|
||||||
|
imported: true,
|
||||||
|
activated: true,
|
||||||
|
explicitlyEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows imported state in verbose output", async () => {
|
||||||
|
buildPluginSnapshotReport.mockReturnValue({
|
||||||
|
plugins: [
|
||||||
|
createPluginRecord({
|
||||||
|
id: "demo",
|
||||||
|
name: "Demo Plugin",
|
||||||
|
imported: false,
|
||||||
|
activated: true,
|
||||||
|
explicitlyEnabled: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPluginsCommand(["plugins", "list", "--verbose"]);
|
||||||
|
|
||||||
|
expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
const output = runtimeLogs.join("\n");
|
||||||
|
expect(output).toContain("activated: yes");
|
||||||
|
expect(output).toContain("imported: no");
|
||||||
|
expect(output).toContain("explicitly enabled: no");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps doctor on a module-loading snapshot", async () => {
|
||||||
|
buildPluginDiagnosticsReport.mockReturnValue({
|
||||||
|
plugins: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await runPluginsCommand(["plugins", "doctor"]);
|
||||||
|
|
||||||
|
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith();
|
||||||
|
expect(runtimeLogs).toContain("No plugin issues detected.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,9 +12,10 @@ import type { PluginRecord } from "../plugins/registry.js";
|
|||||||
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js";
|
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js";
|
||||||
import {
|
import {
|
||||||
buildAllPluginInspectReports,
|
buildAllPluginInspectReports,
|
||||||
|
buildPluginDiagnosticsReport,
|
||||||
buildPluginCompatibilityNotices,
|
buildPluginCompatibilityNotices,
|
||||||
buildPluginInspectReport,
|
buildPluginInspectReport,
|
||||||
buildPluginStatusReport,
|
buildPluginSnapshotReport,
|
||||||
formatPluginCompatibilityNotice,
|
formatPluginCompatibilityNotice,
|
||||||
} from "../plugins/status.js";
|
} from "../plugins/status.js";
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +141,9 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
|||||||
if (plugin.activated !== undefined) {
|
if (plugin.activated !== undefined) {
|
||||||
parts.push(` activated: ${plugin.activated ? "yes" : "no"}`);
|
parts.push(` activated: ${plugin.activated ? "yes" : "no"}`);
|
||||||
}
|
}
|
||||||
|
if (plugin.imported !== undefined) {
|
||||||
|
parts.push(` imported: ${plugin.imported ? "yes" : "no"}`);
|
||||||
|
}
|
||||||
if (plugin.explicitlyEnabled !== undefined) {
|
if (plugin.explicitlyEnabled !== undefined) {
|
||||||
parts.push(` explicitly enabled: ${plugin.explicitlyEnabled ? "yes" : "no"}`);
|
parts.push(` explicitly enabled: ${plugin.explicitlyEnabled ? "yes" : "no"}`);
|
||||||
}
|
}
|
||||||
@@ -236,7 +240,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.option("--enabled", "Only show enabled plugins", false)
|
.option("--enabled", "Only show enabled plugins", false)
|
||||||
.option("--verbose", "Show detailed entries", false)
|
.option("--verbose", "Show detailed entries", false)
|
||||||
.action((opts: PluginsListOptions) => {
|
.action((opts: PluginsListOptions) => {
|
||||||
const report = buildPluginStatusReport();
|
const report = buildPluginSnapshotReport();
|
||||||
const list = opts.enabled
|
const list = opts.enabled
|
||||||
? report.plugins.filter((p) => p.status === "loaded")
|
? report.plugins.filter((p) => p.status === "loaded")
|
||||||
: report.plugins;
|
: report.plugins;
|
||||||
@@ -338,7 +342,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.option("--json", "Print JSON")
|
.option("--json", "Print JSON")
|
||||||
.action((id: string | undefined, opts: PluginInspectOptions) => {
|
.action((id: string | undefined, opts: PluginInspectOptions) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const report = buildPluginStatusReport({ config: cfg });
|
const report = buildPluginDiagnosticsReport({ config: cfg });
|
||||||
if (opts.all) {
|
if (opts.all) {
|
||||||
if (id) {
|
if (id) {
|
||||||
defaultRuntime.error("Pass either a plugin id or --all, not both.");
|
defaultRuntime.error("Pass either a plugin id or --all, not both.");
|
||||||
@@ -603,7 +607,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.action(async (id: string, opts: PluginUninstallOptions) => {
|
.action(async (id: string, opts: PluginUninstallOptions) => {
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||||
const report = buildPluginStatusReport({ config: cfg });
|
const report = buildPluginDiagnosticsReport({ config: cfg });
|
||||||
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
|
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
|
||||||
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
|
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
|
||||||
|
|
||||||
@@ -790,7 +794,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.command("doctor")
|
.command("doctor")
|
||||||
.description("Report plugin load issues")
|
.description("Report plugin load issues")
|
||||||
.action(() => {
|
.action(() => {
|
||||||
const report = buildPluginStatusReport();
|
const report = buildPluginDiagnosticsReport();
|
||||||
const errors = report.plugins.filter((p) => p.status === "error");
|
const errors = report.plugins.filter((p) => p.status === "error");
|
||||||
const diags = report.diagnostics.filter((d) => d.level === "error");
|
const diags = report.diagnostics.filter((d) => d.level === "error");
|
||||||
const compatibility = buildPluginCompatibilityNotices({ report });
|
const compatibility = buildPluginCompatibilityNotices({ report });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
|
|||||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
|
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
|
||||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export function applySlotSelectionForPlugin(
|
|||||||
config: OpenClawConfig,
|
config: OpenClawConfig,
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
): { config: OpenClawConfig; warnings: string[] } {
|
): { config: OpenClawConfig; warnings: string[] } {
|
||||||
const report = buildPluginStatusReport({ config });
|
const report = buildPluginDiagnosticsReport({ config });
|
||||||
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return { config, warnings: [] };
|
return { config, warnings: [] };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
resolveAgentWorkspaceDir: vi.fn(),
|
resolveAgentWorkspaceDir: vi.fn(),
|
||||||
resolveDefaultAgentId: vi.fn(),
|
resolveDefaultAgentId: vi.fn(),
|
||||||
buildWorkspaceSkillStatus: vi.fn(),
|
buildWorkspaceSkillStatus: vi.fn(),
|
||||||
buildPluginStatusReport: vi.fn(),
|
buildPluginDiagnosticsReport: vi.fn(),
|
||||||
buildPluginCompatibilityWarnings: vi.fn(),
|
buildPluginCompatibilityWarnings: vi.fn(),
|
||||||
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
|
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
|
||||||
listTasksForFlowId: vi.fn<(flowId: string) => unknown[]>((_flowId: string) => []),
|
listTasksForFlowId: vi.fn<(flowId: string) => unknown[]>((_flowId: string) => []),
|
||||||
@@ -27,7 +27,7 @@ vi.mock("../agents/skills-status.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/status.js", () => ({
|
vi.mock("../plugins/status.js", () => ({
|
||||||
buildPluginStatusReport: (...args: unknown[]) => mocks.buildPluginStatusReport(...args),
|
buildPluginDiagnosticsReport: (...args: unknown[]) => mocks.buildPluginDiagnosticsReport(...args),
|
||||||
buildPluginCompatibilityWarnings: (...args: unknown[]) =>
|
buildPluginCompatibilityWarnings: (...args: unknown[]) =>
|
||||||
mocks.buildPluginCompatibilityWarnings(...args),
|
mocks.buildPluginCompatibilityWarnings(...args),
|
||||||
}));
|
}));
|
||||||
@@ -53,7 +53,7 @@ async function runNoteWorkspaceStatusForTest(
|
|||||||
mocks.buildWorkspaceSkillStatus.mockReturnValue({
|
mocks.buildWorkspaceSkillStatus.mockReturnValue({
|
||||||
skills: [],
|
skills: [],
|
||||||
});
|
});
|
||||||
mocks.buildPluginStatusReport.mockReturnValue({
|
mocks.buildPluginDiagnosticsReport.mockReturnValue({
|
||||||
workspaceDir: "/workspace",
|
workspaceDir: "/workspace",
|
||||||
...loadResult,
|
...loadResult,
|
||||||
});
|
});
|
||||||
@@ -85,7 +85,7 @@ describe("noteWorkspaceStatus", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(mocks.buildPluginStatusReport).toHaveBeenCalledWith({
|
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
|
||||||
config: {},
|
config: {},
|
||||||
workspaceDir: "/workspace",
|
workspaceDir: "/workspace",
|
||||||
});
|
});
|
||||||
@@ -124,6 +124,30 @@ describe("noteWorkspaceStatus", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes imported plugin counts in the plugins note", async () => {
|
||||||
|
const noteSpy = await runNoteWorkspaceStatusForTest(
|
||||||
|
createPluginLoadResult({
|
||||||
|
plugins: [
|
||||||
|
createPluginRecord({
|
||||||
|
id: "imported-plugin",
|
||||||
|
imported: true,
|
||||||
|
}),
|
||||||
|
createPluginRecord({
|
||||||
|
id: "cold-plugin",
|
||||||
|
imported: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
|
||||||
|
expect(pluginCalls).toHaveLength(1);
|
||||||
|
expect(String(pluginCalls[0]?.[0])).toContain("Imported: 1");
|
||||||
|
} finally {
|
||||||
|
noteSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("omits plugin compatibility note when no legacy compatibility paths are present", async () => {
|
it("omits plugin compatibility note when no legacy compatibility paths are present", async () => {
|
||||||
const noteSpy = await runNoteWorkspaceStatusForTest(
|
const noteSpy = await runNoteWorkspaceStatusForTest(
|
||||||
createPluginLoadResult({
|
createPluginLoadResult({
|
||||||
@@ -158,6 +182,10 @@ describe("noteWorkspaceStatus", () => {
|
|||||||
"legacy-plugin still uses legacy before_agent_start",
|
"legacy-plugin still uses legacy before_agent_start",
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
|
expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({
|
||||||
|
config: {},
|
||||||
|
workspaceDir: "/workspace",
|
||||||
|
});
|
||||||
expect(mocks.buildPluginCompatibilityWarnings).toHaveBeenCalledWith({
|
expect(mocks.buildPluginCompatibilityWarnings).toHaveBeenCalledWith({
|
||||||
config: {},
|
config: {},
|
||||||
workspaceDir: "/workspace",
|
workspaceDir: "/workspace",
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
|
|||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { buildPluginCompatibilityWarnings, buildPluginStatusReport } from "../plugins/status.js";
|
import {
|
||||||
|
buildPluginCompatibilityWarnings,
|
||||||
|
buildPluginDiagnosticsReport,
|
||||||
|
} from "../plugins/status.js";
|
||||||
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
|
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
|
||||||
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
|
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
@@ -69,7 +72,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
|
|||||||
"Skills status",
|
"Skills status",
|
||||||
);
|
);
|
||||||
|
|
||||||
const pluginRegistry = buildPluginStatusReport({
|
const pluginRegistry = buildPluginDiagnosticsReport({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
});
|
});
|
||||||
@@ -77,9 +80,11 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
|
|||||||
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
|
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
|
||||||
const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
|
const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
|
||||||
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
|
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
|
||||||
|
const imported = pluginRegistry.plugins.filter((p) => p.imported);
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`Loaded: ${loaded.length}`,
|
`Loaded: ${loaded.length}`,
|
||||||
|
`Imported: ${imported.length}`,
|
||||||
`Disabled: ${disabled.length}`,
|
`Disabled: ${disabled.length}`,
|
||||||
`Errors: ${errored.length}`,
|
`Errors: ${errored.length}`,
|
||||||
errored.length > 0
|
errored.length > 0
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
canLoadActivatedBundledPluginPublicSurface,
|
canLoadActivatedBundledPluginPublicSurface,
|
||||||
|
listImportedBundledPluginFacadeIds,
|
||||||
loadActivatedBundledPluginPublicSurfaceModuleSync,
|
loadActivatedBundledPluginPublicSurfaceModuleSync,
|
||||||
loadBundledPluginPublicSurfaceModuleSync,
|
loadBundledPluginPublicSurfaceModuleSync,
|
||||||
|
resetFacadeRuntimeStateForTest,
|
||||||
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
|
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
|
||||||
} from "./facade-runtime.js";
|
} from "./facade-runtime.js";
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ function createCircularPluginDir(prefix: string): string {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
clearRuntimeConfigSnapshot();
|
clearRuntimeConfigSnapshot();
|
||||||
|
resetFacadeRuntimeStateForTest();
|
||||||
if (originalBundledPluginsDir === undefined) {
|
if (originalBundledPluginsDir === undefined) {
|
||||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||||
} else {
|
} else {
|
||||||
@@ -114,6 +117,7 @@ describe("plugin-sdk facade runtime", () => {
|
|||||||
});
|
});
|
||||||
expect(first).toBe(second);
|
expect(first).toBe(second);
|
||||||
expect(first.marker).toBe("identity-check");
|
expect(first.marker).toBe("identity-check");
|
||||||
|
expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("breaks circular facade re-entry during module evaluation", () => {
|
it("breaks circular facade re-entry during module evaluation", () => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([
|
|||||||
const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {};
|
const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {};
|
||||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||||
const loadedFacadeModules = new Map<string, unknown>();
|
const loadedFacadeModules = new Map<string, unknown>();
|
||||||
|
const loadedFacadePluginIds = new Set<string>();
|
||||||
let cachedBoundaryRawConfig: OpenClawConfig | undefined;
|
let cachedBoundaryRawConfig: OpenClawConfig | undefined;
|
||||||
let cachedBoundaryResolvedConfig:
|
let cachedBoundaryResolvedConfig:
|
||||||
| {
|
| {
|
||||||
@@ -175,6 +176,10 @@ function resolveBundledPluginManifestRecordByDirName(dirName: string): PluginMan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTrackedFacadePluginId(dirName: string): string {
|
||||||
|
return resolveBundledPluginManifestRecordByDirName(dirName)?.id ?? dirName;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBundledPluginPublicSurfaceAccess(params: {
|
function resolveBundledPluginPublicSurfaceAccess(params: {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
artifactBasename: string;
|
artifactBasename: string;
|
||||||
@@ -334,6 +339,7 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(param
|
|||||||
try {
|
try {
|
||||||
loaded = getJiti(location.modulePath)(location.modulePath) as T;
|
loaded = getJiti(location.modulePath)(location.modulePath) as T;
|
||||||
Object.assign(sentinel, loaded);
|
Object.assign(sentinel, loaded);
|
||||||
|
loadedFacadePluginIds.add(resolveTrackedFacadePluginId(params.dirName));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadedFacadeModules.delete(location.modulePath);
|
loadedFacadeModules.delete(location.modulePath);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -373,3 +379,15 @@ export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync<T extends o
|
|||||||
}
|
}
|
||||||
return loadBundledPluginPublicSurfaceModuleSync<T>(params);
|
return loadBundledPluginPublicSurfaceModuleSync<T>(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listImportedBundledPluginFacadeIds(): string[] {
|
||||||
|
return [...loadedFacadePluginIds].toSorted((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFacadeRuntimeStateForTest(): void {
|
||||||
|
loadedFacadeModules.clear();
|
||||||
|
loadedFacadePluginIds.clear();
|
||||||
|
jitiLoaders.clear();
|
||||||
|
cachedBoundaryRawConfig = undefined;
|
||||||
|
cachedBoundaryResolvedConfig = undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { createEmptyPluginRegistry } from "./registry.js";
|
|||||||
import {
|
import {
|
||||||
getActivePluginRegistry,
|
getActivePluginRegistry,
|
||||||
getActivePluginRegistryKey,
|
getActivePluginRegistryKey,
|
||||||
|
listImportedRuntimePluginIds,
|
||||||
resetPluginRuntimeStateForTest,
|
resetPluginRuntimeStateForTest,
|
||||||
setActivePluginRegistry,
|
setActivePluginRegistry,
|
||||||
} from "./runtime.js";
|
} from "./runtime.js";
|
||||||
@@ -1250,6 +1251,145 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
|
|||||||
expect(fs.existsSync(skippedMarker)).toBe(false);
|
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",
|
label: "keeps scoped plugin loads in a separate cache entry",
|
||||||
run: () => {
|
run: () => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { resolvePluginCacheInputs } from "./roots.js";
|
|||||||
import {
|
import {
|
||||||
getActivePluginRegistry,
|
getActivePluginRegistry,
|
||||||
getActivePluginRegistryKey,
|
getActivePluginRegistryKey,
|
||||||
|
recordImportedPluginId,
|
||||||
setActivePluginRegistry,
|
setActivePluginRegistry,
|
||||||
} from "./runtime.js";
|
} from "./runtime.js";
|
||||||
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
|
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
|
||||||
@@ -96,6 +97,7 @@ export type PluginLoadOptions = {
|
|||||||
*/
|
*/
|
||||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||||
activate?: boolean;
|
activate?: boolean;
|
||||||
|
loadModules?: boolean;
|
||||||
throwOnLoadError?: boolean;
|
throwOnLoadError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,6 +243,7 @@ function buildCacheKey(params: {
|
|||||||
onlyPluginIds?: string[];
|
onlyPluginIds?: string[];
|
||||||
includeSetupOnlyChannelPlugins?: boolean;
|
includeSetupOnlyChannelPlugins?: boolean;
|
||||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||||
|
loadModules?: boolean;
|
||||||
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
||||||
pluginSdkResolution?: PluginSdkResolutionPreference;
|
pluginSdkResolution?: PluginSdkResolutionPreference;
|
||||||
coreGatewayMethodNames?: string[];
|
coreGatewayMethodNames?: string[];
|
||||||
@@ -270,13 +273,14 @@ function buildCacheKey(params: {
|
|||||||
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
|
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
|
||||||
const startupChannelMode =
|
const startupChannelMode =
|
||||||
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
|
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
|
||||||
|
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
|
||||||
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
||||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||||
...params.plugins,
|
...params.plugins,
|
||||||
installs,
|
installs,
|
||||||
loadPaths,
|
loadPaths,
|
||||||
activationMetadataKey: params.activationMetadataKey ?? "",
|
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 {
|
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
|
||||||
@@ -360,7 +364,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
|
|||||||
options.pluginSdkResolution !== undefined ||
|
options.pluginSdkResolution !== undefined ||
|
||||||
options.coreGatewayHandlers !== undefined ||
|
options.coreGatewayHandlers !== undefined ||
|
||||||
options.includeSetupOnlyChannelPlugins === true ||
|
options.includeSetupOnlyChannelPlugins === true ||
|
||||||
options.preferSetupRuntimeForChannelPlugins === true,
|
options.preferSetupRuntimeForChannelPlugins === true ||
|
||||||
|
options.loadModules === false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +392,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|||||||
onlyPluginIds,
|
onlyPluginIds,
|
||||||
includeSetupOnlyChannelPlugins,
|
includeSetupOnlyChannelPlugins,
|
||||||
preferSetupRuntimeForChannelPlugins,
|
preferSetupRuntimeForChannelPlugins,
|
||||||
|
loadModules: options.loadModules,
|
||||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||||
pluginSdkResolution: options.pluginSdkResolution,
|
pluginSdkResolution: options.pluginSdkResolution,
|
||||||
coreGatewayMethodNames,
|
coreGatewayMethodNames,
|
||||||
@@ -402,6 +408,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|||||||
includeSetupOnlyChannelPlugins,
|
includeSetupOnlyChannelPlugins,
|
||||||
preferSetupRuntimeForChannelPlugins,
|
preferSetupRuntimeForChannelPlugins,
|
||||||
shouldActivate: options.activate !== false,
|
shouldActivate: options.activate !== false,
|
||||||
|
shouldLoadModules: options.loadModules !== false,
|
||||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||||
cacheKey,
|
cacheKey,
|
||||||
};
|
};
|
||||||
@@ -924,6 +931,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
includeSetupOnlyChannelPlugins,
|
includeSetupOnlyChannelPlugins,
|
||||||
preferSetupRuntimeForChannelPlugins,
|
preferSetupRuntimeForChannelPlugins,
|
||||||
shouldActivate,
|
shouldActivate,
|
||||||
|
shouldLoadModules,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
runtimeSubagentMode,
|
runtimeSubagentMode,
|
||||||
} = resolvePluginLoadCacheContext(options);
|
} = resolvePluginLoadCacheContext(options);
|
||||||
@@ -1306,6 +1314,49 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
continue;
|
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 pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||||
const loadSource =
|
const loadSource =
|
||||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||||
@@ -1328,6 +1379,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
|
|
||||||
let mod: OpenClawPluginModule | null = null;
|
let mod: OpenClawPluginModule | null = null;
|
||||||
try {
|
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;
|
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
recordPluginError({
|
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) {
|
if (validateOnly) {
|
||||||
registry.plugins.push(record);
|
registry.plugins.push(record);
|
||||||
seenIds.set(pluginId, candidate.origin);
|
seenIds.set(pluginId, candidate.origin);
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export type PluginRecord = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
explicitlyEnabled?: boolean;
|
explicitlyEnabled?: boolean;
|
||||||
activated?: boolean;
|
activated?: boolean;
|
||||||
|
imported?: boolean;
|
||||||
activationSource?: PluginActivationSource;
|
activationSource?: PluginActivationSource;
|
||||||
activationReason?: string;
|
activationReason?: string;
|
||||||
status: "loaded" | "disabled" | "error";
|
status: "loaded" | "disabled" | "error";
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getActivePluginHttpRouteRegistryVersion,
|
getActivePluginHttpRouteRegistryVersion,
|
||||||
getActivePluginRegistryVersion,
|
getActivePluginRegistryVersion,
|
||||||
getActivePluginRegistry,
|
getActivePluginRegistry,
|
||||||
|
listImportedRuntimePluginIds,
|
||||||
pinActivePluginHttpRouteRegistry,
|
pinActivePluginHttpRouteRegistry,
|
||||||
|
recordImportedPluginId,
|
||||||
releasePinnedPluginHttpRouteRegistry,
|
releasePinnedPluginHttpRouteRegistry,
|
||||||
resetPluginRuntimeStateForTest,
|
resetPluginRuntimeStateForTest,
|
||||||
resolveActivePluginHttpRouteRegistry,
|
resolveActivePluginHttpRouteRegistry,
|
||||||
@@ -180,6 +182,72 @@ describe("setActivePluginRegistry", () => {
|
|||||||
setActivePluginRegistry(registry);
|
setActivePluginRegistry(registry);
|
||||||
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
|
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", () => {
|
describe("setActivePluginRegistry", () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type RegistryState = {
|
|||||||
channel: RegistrySurfaceState;
|
channel: RegistrySurfaceState;
|
||||||
key: string | null;
|
key: string | null;
|
||||||
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable";
|
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable";
|
||||||
|
importedPluginIds: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const state: RegistryState = (() => {
|
const state: RegistryState = (() => {
|
||||||
@@ -38,11 +39,16 @@ const state: RegistryState = (() => {
|
|||||||
},
|
},
|
||||||
key: null,
|
key: null,
|
||||||
runtimeSubagentMode: "default",
|
runtimeSubagentMode: "default",
|
||||||
|
importedPluginIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return globalState[REGISTRY_STATE];
|
return globalState[REGISTRY_STATE];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
export function recordImportedPluginId(pluginId: string): void {
|
||||||
|
state.importedPluginIds.add(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
function installSurfaceRegistry(
|
function installSurfaceRegistry(
|
||||||
surface: RegistrySurfaceState,
|
surface: RegistrySurfaceState,
|
||||||
registry: PluginRegistry | null,
|
registry: PluginRegistry | null,
|
||||||
@@ -190,6 +196,39 @@ export function getActivePluginRegistryVersion(): number {
|
|||||||
return state.activeVersion;
|
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 {
|
export function resetPluginRuntimeStateForTest(): void {
|
||||||
state.activeRegistry = null;
|
state.activeRegistry = null;
|
||||||
state.activeVersion += 1;
|
state.activeVersion += 1;
|
||||||
@@ -197,4 +236,5 @@ export function resetPluginRuntimeStateForTest(): void {
|
|||||||
installSurfaceRegistry(state.channel, null, false);
|
installSurfaceRegistry(state.channel, null, false);
|
||||||
state.key = null;
|
state.key = null;
|
||||||
state.runtimeSubagentMode = "default";
|
state.runtimeSubagentMode = "default";
|
||||||
|
state.importedPluginIds.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ const applyPluginAutoEnableMock = vi.fn();
|
|||||||
const resolveBundledProviderCompatPluginIdsMock = vi.fn();
|
const resolveBundledProviderCompatPluginIdsMock = vi.fn();
|
||||||
const withBundledPluginAllowlistCompatMock = vi.fn();
|
const withBundledPluginAllowlistCompatMock = vi.fn();
|
||||||
const withBundledPluginEnablementCompatMock = vi.fn();
|
const withBundledPluginEnablementCompatMock = vi.fn();
|
||||||
|
const listImportedBundledPluginFacadeIdsMock = vi.fn();
|
||||||
|
const listImportedRuntimePluginIdsMock = vi.fn();
|
||||||
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
|
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 buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
|
||||||
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
|
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
|
||||||
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
|
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
|
||||||
@@ -47,6 +51,15 @@ vi.mock("./bundled-compat.js", () => ({
|
|||||||
withBundledPluginEnablementCompatMock(...args),
|
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", () => ({
|
vi.mock("../agents/agent-scope.js", () => ({
|
||||||
resolveAgentWorkspaceDir: () => undefined,
|
resolveAgentWorkspaceDir: () => undefined,
|
||||||
resolveDefaultAgentId: () => "default",
|
resolveDefaultAgentId: () => "default",
|
||||||
@@ -92,6 +105,7 @@ function expectPluginLoaderCall(params: {
|
|||||||
autoEnabledReasons?: Record<string, string[]>;
|
autoEnabledReasons?: Record<string, string[]>;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
loadModules?: boolean;
|
||||||
}) {
|
}) {
|
||||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -104,6 +118,7 @@ function expectPluginLoaderCall(params: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||||
...(params.env ? { env: params.env } : {}),
|
...(params.env ? { env: params.env } : {}),
|
||||||
|
...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,8 +247,10 @@ describe("buildPluginStatusReport", () => {
|
|||||||
({
|
({
|
||||||
buildAllPluginInspectReports,
|
buildAllPluginInspectReports,
|
||||||
buildPluginCompatibilityNotices,
|
buildPluginCompatibilityNotices,
|
||||||
|
buildPluginDiagnosticsReport,
|
||||||
buildPluginCompatibilityWarnings,
|
buildPluginCompatibilityWarnings,
|
||||||
buildPluginInspectReport,
|
buildPluginInspectReport,
|
||||||
|
buildPluginSnapshotReport,
|
||||||
buildPluginStatusReport,
|
buildPluginStatusReport,
|
||||||
formatPluginCompatibilityNotice,
|
formatPluginCompatibilityNotice,
|
||||||
summarizePluginCompatibility,
|
summarizePluginCompatibility,
|
||||||
@@ -247,6 +264,8 @@ describe("buildPluginStatusReport", () => {
|
|||||||
resolveBundledProviderCompatPluginIdsMock.mockReset();
|
resolveBundledProviderCompatPluginIdsMock.mockReset();
|
||||||
withBundledPluginAllowlistCompatMock.mockReset();
|
withBundledPluginAllowlistCompatMock.mockReset();
|
||||||
withBundledPluginEnablementCompatMock.mockReset();
|
withBundledPluginEnablementCompatMock.mockReset();
|
||||||
|
listImportedBundledPluginFacadeIdsMock.mockReset();
|
||||||
|
listImportedRuntimePluginIdsMock.mockReset();
|
||||||
loadConfigMock.mockReturnValue({});
|
loadConfigMock.mockReturnValue({});
|
||||||
applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({
|
applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({
|
||||||
config: params.config,
|
config: params.config,
|
||||||
@@ -260,13 +279,15 @@ describe("buildPluginStatusReport", () => {
|
|||||||
withBundledPluginEnablementCompatMock.mockImplementation(
|
withBundledPluginEnablementCompatMock.mockImplementation(
|
||||||
(params: { config: unknown }) => params.config,
|
(params: { config: unknown }) => params.config,
|
||||||
);
|
);
|
||||||
|
listImportedBundledPluginFacadeIdsMock.mockReturnValue([]);
|
||||||
|
listImportedRuntimePluginIdsMock.mockReturnValue([]);
|
||||||
setPluginLoadResult({ plugins: [] });
|
setPluginLoadResult({ plugins: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards an explicit env to plugin loading", () => {
|
it("forwards an explicit env to plugin loading", () => {
|
||||||
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
|
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
buildPluginStatusReport({
|
buildPluginSnapshotReport({
|
||||||
config: {},
|
config: {},
|
||||||
workspaceDir: "/workspace",
|
workspaceDir: "/workspace",
|
||||||
env,
|
env,
|
||||||
@@ -276,9 +297,22 @@ describe("buildPluginStatusReport", () => {
|
|||||||
config: {},
|
config: {},
|
||||||
workspaceDir: "/workspace",
|
workspaceDir: "/workspace",
|
||||||
env,
|
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", () => {
|
it("loads plugin status from the auto-enabled config snapshot", () => {
|
||||||
const { rawConfig, autoEnabledConfig } = createAutoEnabledStatusConfig(
|
const { rawConfig, autoEnabledConfig } = createAutoEnabledStatusConfig(
|
||||||
{
|
{
|
||||||
@@ -294,7 +328,7 @@ describe("buildPluginStatusReport", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
buildPluginStatusReport({ config: rawConfig });
|
buildPluginSnapshotReport({ config: rawConfig });
|
||||||
|
|
||||||
expectAutoEnabledStatusLoad({
|
expectAutoEnabledStatusLoad({
|
||||||
rawConfig,
|
rawConfig,
|
||||||
@@ -303,6 +337,7 @@ describe("buildPluginStatusReport", () => {
|
|||||||
demo: ["demo configured"],
|
demo: ["demo configured"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expectPluginLoaderCall({ loadModules: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the auto-enabled config snapshot for inspect policy summaries", () => {
|
it("uses the auto-enabled config snapshot for inspect policy summaries", () => {
|
||||||
@@ -345,6 +380,7 @@ describe("buildPluginStatusReport", () => {
|
|||||||
allowedModels: ["openai/gpt-5.4"],
|
allowedModels: ["openai/gpt-5.4"],
|
||||||
hasAllowedModelsConfig: true,
|
hasAllowedModelsConfig: true,
|
||||||
});
|
});
|
||||||
|
expectPluginLoaderCall({ loadModules: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves raw config activation context when compatibility notices build their own report", () => {
|
it("preserves raw config activation context when compatibility notices build their own report", () => {
|
||||||
@@ -386,6 +422,7 @@ describe("buildPluginStatusReport", () => {
|
|||||||
demo: ["demo configured"],
|
demo: ["demo configured"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expectPluginLoaderCall({ loadModules: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies the full bundled provider compat chain before loading plugins", () => {
|
it("applies the full bundled provider compat chain before loading plugins", () => {
|
||||||
@@ -395,7 +432,7 @@ describe("buildPluginStatusReport", () => {
|
|||||||
withBundledPluginAllowlistCompatMock.mockReturnValue(compatConfig);
|
withBundledPluginAllowlistCompatMock.mockReturnValue(compatConfig);
|
||||||
withBundledPluginEnablementCompatMock.mockReturnValue(enabledConfig);
|
withBundledPluginEnablementCompatMock.mockReturnValue(enabledConfig);
|
||||||
|
|
||||||
buildPluginStatusReport({ config });
|
buildPluginSnapshotReport({ config });
|
||||||
|
|
||||||
expectBundledCompatChainApplied({
|
expectBundledCompatChainApplied({
|
||||||
config,
|
config,
|
||||||
@@ -427,6 +464,63 @@ describe("buildPluginStatusReport", () => {
|
|||||||
expect(report.plugins[0]?.version).toBe("2026.3.23");
|
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", () => {
|
it("builds an inspect report with capability shape and policy", () => {
|
||||||
loadConfigMock.mockReturnValue({
|
loadConfigMock.mockReturnValue({
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
import { normalizeOpenClawVersionBase } from "../config/version.js";
|
import { normalizeOpenClawVersionBase } from "../config/version.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import { listImportedBundledPluginFacadeIds } from "../plugin-sdk/facade-runtime.js";
|
||||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||||
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
|
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
|
||||||
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
||||||
@@ -16,6 +17,7 @@ import { loadOpenClawPlugins } from "./loader.js";
|
|||||||
import { createPluginLoaderLogger } from "./logger.js";
|
import { createPluginLoaderLogger } from "./logger.js";
|
||||||
import { resolveBundledProviderCompatPluginIds } from "./providers.js";
|
import { resolveBundledProviderCompatPluginIds } from "./providers.js";
|
||||||
import type { PluginRegistry } from "./registry.js";
|
import type { PluginRegistry } from "./registry.js";
|
||||||
|
import { listImportedRuntimePluginIds } from "./runtime.js";
|
||||||
import type { PluginDiagnostic, PluginHookName } from "./types.js";
|
import type { PluginDiagnostic, PluginHookName } from "./types.js";
|
||||||
|
|
||||||
export type PluginStatusReport = PluginRegistry & {
|
export type PluginStatusReport = PluginRegistry & {
|
||||||
@@ -147,12 +149,17 @@ function resolveReportedPluginVersion(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPluginStatusReport(params?: {
|
type PluginReportParams = {
|
||||||
config?: ReturnType<typeof loadConfig>;
|
config?: ReturnType<typeof loadConfig>;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): PluginStatusReport {
|
};
|
||||||
|
|
||||||
|
function buildPluginReport(
|
||||||
|
params: PluginReportParams | undefined,
|
||||||
|
loadModules: boolean,
|
||||||
|
): PluginStatusReport {
|
||||||
const rawConfig = params?.config ?? loadConfig();
|
const rawConfig = params?.config ?? loadConfig();
|
||||||
const autoEnabled = resolveStatusConfig(rawConfig, params?.env);
|
const autoEnabled = resolveStatusConfig(rawConfig, params?.env);
|
||||||
const config = autoEnabled.config;
|
const config = autoEnabled.config;
|
||||||
@@ -188,18 +195,45 @@ export function buildPluginStatusReport(params?: {
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
env: params?.env,
|
env: params?.env,
|
||||||
logger: createPluginLoaderLogger(log),
|
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 {
|
return {
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
...registry,
|
...registry,
|
||||||
plugins: registry.plugins.map((plugin) => ({
|
plugins: registry.plugins.map((plugin) => ({
|
||||||
...plugin,
|
...plugin,
|
||||||
|
imported: plugin.format !== "bundle" && importedPluginIds.has(plugin.id),
|
||||||
version: resolveReportedPluginVersion(plugin, params?.env),
|
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]) {
|
function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) {
|
||||||
return [
|
return [
|
||||||
{ kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] },
|
{ kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] },
|
||||||
@@ -255,7 +289,7 @@ export function buildPluginInspectReport(params: {
|
|||||||
const config = resolvedConfig.config;
|
const config = resolvedConfig.config;
|
||||||
const report =
|
const report =
|
||||||
params.report ??
|
params.report ??
|
||||||
buildPluginStatusReport({
|
buildPluginDiagnosticsReport({
|
||||||
config: rawConfig,
|
config: rawConfig,
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
@@ -388,7 +422,7 @@ export function buildAllPluginInspectReports(params?: {
|
|||||||
const rawConfig = params?.config ?? loadConfig();
|
const rawConfig = params?.config ?? loadConfig();
|
||||||
const report =
|
const report =
|
||||||
params?.report ??
|
params?.report ??
|
||||||
buildPluginStatusReport({
|
buildPluginDiagnosticsReport({
|
||||||
config: rawConfig,
|
config: rawConfig,
|
||||||
workspaceDir: params?.workspaceDir,
|
workspaceDir: params?.workspaceDir,
|
||||||
env: params?.env,
|
env: params?.env,
|
||||||
|
|||||||
Reference in New Issue
Block a user