diff --git a/src/auto-reply/reply/commands-plugins.toggle.test.ts b/src/auto-reply/reply/commands-plugins.toggle.test.ts index cf6711cb765..97ed696da31 100644 --- a/src/auto-reply/reply/commands-plugins.toggle.test.ts +++ b/src/auto-reply/reply/commands-plugins.toggle.test.ts @@ -8,12 +8,14 @@ const { readConfigFileSnapshotMock, validateConfigObjectWithPluginsMock, writeConfigFileMock, - buildPluginStatusReportMock, + buildPluginSnapshotReportMock, + buildPluginDiagnosticsReportMock, } = vi.hoisted(() => ({ readConfigFileSnapshotMock: vi.fn(), validateConfigObjectWithPluginsMock: vi.fn(), writeConfigFileMock: vi.fn(), - buildPluginStatusReportMock: vi.fn(), + buildPluginSnapshotReportMock: vi.fn(), + buildPluginDiagnosticsReportMock: vi.fn(), })); vi.mock("../../config/config.js", async () => { @@ -32,7 +34,8 @@ vi.mock("../../plugins/status.js", async () => { await vi.importActual("../../plugins/status.js"); return { ...actual, - buildPluginStatusReport: buildPluginStatusReportMock, + buildPluginSnapshotReport: buildPluginSnapshotReportMock, + buildPluginDiagnosticsReport: buildPluginDiagnosticsReportMock, }; }); @@ -56,7 +59,8 @@ describe("handleCommands /plugins toggle", () => { readConfigFileSnapshotMock.mockReset(); validateConfigObjectWithPluginsMock.mockReset(); writeConfigFileMock.mockReset(); - buildPluginStatusReportMock.mockReset(); + buildPluginSnapshotReportMock.mockReset(); + buildPluginDiagnosticsReportMock.mockReset(); }); it("enables a discovered plugin", async () => { @@ -66,7 +70,7 @@ describe("handleCommands /plugins toggle", () => { path: "/tmp/openclaw.json", resolved: config, }); - buildPluginStatusReportMock.mockReturnValue( + buildPluginDiagnosticsReportMock.mockReturnValue( createPluginStatusReport({ workspaceDir: "/tmp/workspace", plugins: [ @@ -106,7 +110,7 @@ describe("handleCommands /plugins toggle", () => { path: "/tmp/openclaw.json", resolved: config, }); - buildPluginStatusReportMock.mockReturnValue( + buildPluginDiagnosticsReportMock.mockReturnValue( createPluginStatusReport({ workspaceDir: "/tmp/workspace", 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(); + }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index b8eeb299f9e..245ca6f0828 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -22,8 +22,9 @@ import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registr import type { PluginRecord } from "../../plugins/registry.js"; import { buildAllPluginInspectReports, + buildPluginDiagnosticsReport, buildPluginInspectReport, - buildPluginStatusReport, + buildPluginSnapshotReport, formatPluginCompatibilityNotice, type PluginStatusReport, } from "../../plugins/status.js"; @@ -272,7 +273,10 @@ async function installPluginFromPluginsCommand(params: { return { ok: true, pluginId: result.pluginId }; } -async function loadPluginCommandState(workspaceDir: string): Promise< +async function loadPluginCommandState( + workspaceDir: string, + options?: { loadModules?: boolean }, +): Promise< | { ok: true; path: string; @@ -294,7 +298,10 @@ async function loadPluginCommandState(workspaceDir: string): Promise< ok: true, path: snapshot.path, 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) { return { shouldContinue: false, diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index e3ab14f82e6..51f8b97ede1 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -10,7 +10,7 @@ import { import { resolveHookEntries } from "../hooks/policy.js"; import type { HookEntry } from "../hooks/types.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 { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; @@ -46,7 +46,7 @@ function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntr function buildHooksReport(config: OpenClawConfig): HookStatusReport { const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(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 entries = mergeHookEntries(pluginEntries, workspaceEntries); return buildWorkspaceHookStatus(workspaceDir, { config, entries }); diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a3d303169c5..df4458b777f 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -18,7 +18,10 @@ export const resolveMarketplaceInstallShortcut = vi.fn(); export const enablePluginInConfig = vi.fn(); export const recordPluginInstall = 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 uninstallPlugin = vi.fn(); export const updateNpmInstalledPlugins = vi.fn(); @@ -72,7 +75,9 @@ vi.mock("../plugins/manifest-registry.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", () => ({ @@ -154,7 +159,9 @@ export function resetPluginsCliTestState() { enablePluginInConfig.mockReset(); recordPluginInstall.mockReset(); clearPluginManifestRegistryCache.mockReset(); - buildPluginStatusReport.mockReset(); + buildPluginSnapshotReport.mockReset(); + buildPluginDiagnosticsReport.mockReset(); + buildPluginCompatibilityNotices.mockReset(); applyExclusiveSlotSelection.mockReset(); uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); @@ -199,10 +206,13 @@ export function resetPluginsCliTestState() { }); enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); - buildPluginStatusReport.mockReturnValue({ + const defaultPluginReport = { plugins: [], diagnostics: [], - }); + }; + buildPluginSnapshotReport.mockReturnValue(defaultPluginReport); + buildPluginDiagnosticsReport.mockReturnValue(defaultPluginReport); + buildPluginCompatibilityNotices.mockReturnValue([]); applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ config, warnings: [], diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts new file mode 100644 index 00000000000..c77d38be4f1 --- /dev/null +++ b/src/cli/plugins-cli.list.test.ts @@ -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."); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 7c595a25cc9..8de55764aa0 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -12,9 +12,10 @@ import type { PluginRecord } from "../plugins/registry.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; import { buildAllPluginInspectReports, + buildPluginDiagnosticsReport, buildPluginCompatibilityNotices, buildPluginInspectReport, - buildPluginStatusReport, + buildPluginSnapshotReport, formatPluginCompatibilityNotice, } from "../plugins/status.js"; import { @@ -140,6 +141,9 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { if (plugin.activated !== undefined) { parts.push(` activated: ${plugin.activated ? "yes" : "no"}`); } + if (plugin.imported !== undefined) { + parts.push(` imported: ${plugin.imported ? "yes" : "no"}`); + } if (plugin.explicitlyEnabled !== undefined) { 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("--verbose", "Show detailed entries", false) .action((opts: PluginsListOptions) => { - const report = buildPluginStatusReport(); + const report = buildPluginSnapshotReport(); const list = opts.enabled ? report.plugins.filter((p) => p.status === "loaded") : report.plugins; @@ -338,7 +342,7 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .action((id: string | undefined, opts: PluginInspectOptions) => { const cfg = loadConfig(); - const report = buildPluginStatusReport({ config: cfg }); + const report = buildPluginDiagnosticsReport({ config: cfg }); if (opts.all) { if (id) { 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) => { const snapshot = await readConfigFileSnapshot(); 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 keepFiles = Boolean(opts.keepFiles || opts.keepConfig); @@ -790,7 +794,7 @@ export function registerPluginsCli(program: Command) { .command("doctor") .description("Report plugin load issues") .action(() => { - const report = buildPluginStatusReport(); + const report = buildPluginDiagnosticsReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); const compatibility = buildPluginCompatibilityNotices({ report }); diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 931432799ca..ef842c6c913 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.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 { theme } from "../terminal/theme.js"; @@ -40,7 +40,7 @@ export function applySlotSelectionForPlugin( config: OpenClawConfig, pluginId: string, ): { config: OpenClawConfig; warnings: string[] } { - const report = buildPluginStatusReport({ config }); + const report = buildPluginDiagnosticsReport({ config }); const plugin = report.plugins.find((entry) => entry.id === pluginId); if (!plugin) { return { config, warnings: [] }; diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 6886d8ee8cb..35b86a4c576 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentId: vi.fn(), buildWorkspaceSkillStatus: vi.fn(), - buildPluginStatusReport: vi.fn(), + buildPluginDiagnosticsReport: vi.fn(), buildPluginCompatibilityWarnings: vi.fn(), listTaskFlowRecords: vi.fn<() => unknown[]>(() => []), listTasksForFlowId: vi.fn<(flowId: string) => unknown[]>((_flowId: string) => []), @@ -27,7 +27,7 @@ vi.mock("../agents/skills-status.js", () => ({ })); vi.mock("../plugins/status.js", () => ({ - buildPluginStatusReport: (...args: unknown[]) => mocks.buildPluginStatusReport(...args), + buildPluginDiagnosticsReport: (...args: unknown[]) => mocks.buildPluginDiagnosticsReport(...args), buildPluginCompatibilityWarnings: (...args: unknown[]) => mocks.buildPluginCompatibilityWarnings(...args), })); @@ -53,7 +53,7 @@ async function runNoteWorkspaceStatusForTest( mocks.buildWorkspaceSkillStatus.mockReturnValue({ skills: [], }); - mocks.buildPluginStatusReport.mockReturnValue({ + mocks.buildPluginDiagnosticsReport.mockReturnValue({ workspaceDir: "/workspace", ...loadResult, }); @@ -85,7 +85,7 @@ describe("noteWorkspaceStatus", () => { }), ); try { - expect(mocks.buildPluginStatusReport).toHaveBeenCalledWith({ + expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({ config: {}, 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 () => { const noteSpy = await runNoteWorkspaceStatusForTest( createPluginLoadResult({ @@ -158,6 +182,10 @@ describe("noteWorkspaceStatus", () => { "legacy-plugin still uses legacy before_agent_start", ]); try { + expect(mocks.buildPluginDiagnosticsReport).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/workspace", + }); expect(mocks.buildPluginCompatibilityWarnings).toHaveBeenCalledWith({ config: {}, workspaceDir: "/workspace", diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 44f267a0b82..d5fb74b1c5e 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -2,7 +2,10 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { formatCliCommand } from "../cli/command-format.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 { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js"; import { note } from "../terminal/note.js"; @@ -69,7 +72,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { "Skills status", ); - const pluginRegistry = buildPluginStatusReport({ + const pluginRegistry = buildPluginDiagnosticsReport({ config: cfg, workspaceDir, }); @@ -77,9 +80,11 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded"); const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled"); const errored = pluginRegistry.plugins.filter((p) => p.status === "error"); + const imported = pluginRegistry.plugins.filter((p) => p.imported); const lines = [ `Loaded: ${loaded.length}`, + `Imported: ${imported.length}`, `Disabled: ${disabled.length}`, `Errors: ${errored.length}`, errored.length > 0 diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 506b9ad9467..a0e2914c858 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -5,8 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import { canLoadActivatedBundledPluginPublicSurface, + listImportedBundledPluginFacadeIds, loadActivatedBundledPluginPublicSurfaceModuleSync, loadBundledPluginPublicSurfaceModuleSync, + resetFacadeRuntimeStateForTest, tryLoadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; @@ -70,6 +72,7 @@ function createCircularPluginDir(prefix: string): string { afterEach(() => { vi.restoreAllMocks(); clearRuntimeConfigSnapshot(); + resetFacadeRuntimeStateForTest(); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -114,6 +117,7 @@ describe("plugin-sdk facade runtime", () => { }); expect(first).toBe(second); expect(first.marker).toBe("identity-check"); + expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]); }); it("breaks circular facade re-entry during module evaluation", () => { diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index dfd8edf555d..d0d23a81269 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -37,6 +37,7 @@ const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([ const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {}; const jitiLoaders = new Map>(); const loadedFacadeModules = new Map(); +const loadedFacadePluginIds = new Set(); let cachedBoundaryRawConfig: OpenClawConfig | undefined; let cachedBoundaryResolvedConfig: | { @@ -175,6 +176,10 @@ function resolveBundledPluginManifestRecordByDirName(dirName: string): PluginMan ); } +function resolveTrackedFacadePluginId(dirName: string): string { + return resolveBundledPluginManifestRecordByDirName(dirName)?.id ?? dirName; +} + function resolveBundledPluginPublicSurfaceAccess(params: { dirName: string; artifactBasename: string; @@ -334,6 +339,7 @@ export function loadBundledPluginPublicSurfaceModuleSync(param try { loaded = getJiti(location.modulePath)(location.modulePath) as T; Object.assign(sentinel, loaded); + loadedFacadePluginIds.add(resolveTrackedFacadePluginId(params.dirName)); } catch (err) { loadedFacadeModules.delete(location.modulePath); throw err; @@ -373,3 +379,15 @@ export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync(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; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index f3af8a3df98..5a7d7e306e9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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: () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 800172cf0e7..13f4318b947 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b60b31a0c55..b0d783a55cf 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -200,6 +200,7 @@ export type PluginRecord = { enabled: boolean; explicitlyEnabled?: boolean; activated?: boolean; + imported?: boolean; activationSource?: PluginActivationSource; activationReason?: string; status: "loaded" | "disabled" | "error"; diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 046e0f002d1..47213675b25 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -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", () => { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 2f512b7e83d..175a8df790f 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -16,6 +16,7 @@ type RegistryState = { channel: RegistrySurfaceState; key: string | null; runtimeSubagentMode: "default" | "explicit" | "gateway-bindable"; + importedPluginIds: Set; }; const state: RegistryState = (() => { @@ -38,11 +39,16 @@ const state: RegistryState = (() => { }, key: null, runtimeSubagentMode: "default", + importedPluginIds: new Set(), }; } 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, +): 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(); } diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index d377b66b468..2d1914fdbbd 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -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; 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: { diff --git a/src/plugins/status.ts b/src/plugins/status.ts index aa4b23bba2c..64fdc849831 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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; 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,