fix(cli): make status and provider diagnostics complete (#116776)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 04:10:36 -07:00
committed by GitHub
parent 718e9c8820
commit 4e4f76f151
15 changed files with 479 additions and 37 deletions
@@ -1,8 +1,16 @@
// Slack tests cover account inspection and credential status reporting.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { isSlackPluginAccountConfigured } from "./account-configured.js";
import { inspectSlackAccount } from "./account-inspect.js";
function isInspectedSlackAccountUsable(account: ReturnType<typeof inspectSlackAccount>): boolean {
return isSlackPluginAccountConfigured({
...account,
identity: account.identity ?? "bot",
});
}
describe("inspectSlackAccount", () => {
it("reports user-token source and status for a configured user identity", () => {
const account = inspectSlackAccount({
@@ -107,6 +115,63 @@ describe("inspectSlackAccount", () => {
expect(account).toMatchObject({
botTokenSource: "config",
botTokenStatus: "configured_unavailable",
configured: true,
});
expect(isInspectedSlackAccountUsable(account)).toBe(false);
});
it("keeps a healthy bot identity configured when its optional user token is unavailable", () => {
const account = inspectSlackAccount({
cfg: {
channels: {
slack: {
botToken: "test-bot-token",
appToken: "test-app-token",
userToken: {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_MISSING_OPTIONAL_SLACK_USER_TOKEN",
},
},
},
} as OpenClawConfig,
envBotToken: "",
envAppToken: "",
envUserToken: "",
});
expect(account).toMatchObject({
configured: true,
botTokenStatus: "available",
appTokenStatus: "available",
userTokenStatus: "configured_unavailable",
});
expect(isInspectedSlackAccountUsable(account)).toBe(true);
});
it("keeps incomplete required credentials unconfigured even when another token is unavailable", () => {
const account = inspectSlackAccount({
cfg: {
channels: {
slack: {
botToken: {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_MISSING_REQUIRED_SLACK_BOT_TOKEN",
},
},
},
} as OpenClawConfig,
envBotToken: "",
envAppToken: "",
envUserToken: "",
});
expect(account).toMatchObject({
configured: false,
botTokenStatus: "configured_unavailable",
appTokenStatus: "missing",
});
expect(isInspectedSlackAccountUsable(account)).toBe(false);
});
});
+152 -4
View File
@@ -101,14 +101,14 @@ describe("buildProviderStatusIndex", () => {
expect(status?.name).toBe("Work");
});
it("records accounts that throw during read-only resolution as not configured", async () => {
it("keeps unresolved configured SecretRef accounts visible without exposing their refs", async () => {
const plugin = {
id: "quietchat",
meta: { label: "QuietChat" },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => {
throw new Error("unresolved SecretRef");
throw new Error("unresolved SecretRef: PRIVATE_PROVIDER_TOKEN");
},
},
status: {},
@@ -123,13 +123,161 @@ describe("buildProviderStatusIndex", () => {
"quietchat:default",
{
provider: "quietchat",
providerLabel: "QuietChat",
accountId: "default",
state: "not configured",
configured: false,
state: "configured unavailable",
configured: true,
visibleInConfiguredLists: true,
},
],
]),
);
const statuses = await buildProviderStatusIndex({} as OpenClawConfig);
expect(
listProvidersForAgent({
summaryIsDefault: true,
cfg: {} as OpenClawConfig,
bindings: [],
providerStatus: statuses,
providerMetadata: buildProviderSummaryMetadataIndex({} as OpenClawConfig),
}),
).toEqual(["QuietChat default: configured unavailable"]);
expect(JSON.stringify([...statuses.values()])).not.toContain("PRIVATE_PROVIDER_TOKEN");
});
it("keeps configured-but-unavailable Telegram-style accounts in default agent output", async () => {
const account = {
accountId: "default",
enabled: true,
configured: true,
tokenStatus: "configured_unavailable" as const,
};
const plugin = {
id: "telegram",
meta: { label: "Telegram" },
config: {
listAccountIds: () => ["default"],
inspectAccount: () => account,
resolveAccount: () => account,
describeAccount: () => ({
accountId: "default",
enabled: true,
configured: true,
tokenStatus: "configured_unavailable" as const,
}),
isConfigured: () => false,
},
status: {},
} as never;
const cfg = {
channels: {
telegram: {
enabled: true,
tokenFile: "/nonexistent/token",
},
},
} as OpenClawConfig;
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]);
const statuses = await buildProviderStatusIndex(cfg);
expect(statuses.get("telegram:default")).toMatchObject({
configured: true,
state: "configured unavailable",
});
expect(
listProvidersForAgent({
summaryIsDefault: true,
cfg,
bindings: [],
providerStatus: statuses,
providerMetadata: buildProviderSummaryMetadataIndex(cfg),
}),
).toEqual(["Telegram default: configured unavailable"]);
});
it("does not mark a healthy Slack account unavailable for an optional unresolved user token", async () => {
const account = {
accountId: "default",
enabled: true,
configured: true,
botTokenStatus: "available" as const,
appTokenStatus: "available" as const,
userTokenStatus: "configured_unavailable" as const,
};
const plugin = {
id: "slack",
meta: { label: "Slack" },
config: {
listAccountIds: () => ["default"],
inspectAccount: () => account,
resolveAccount: () => account,
describeAccount: () => ({ accountId: "default", enabled: true, configured: true }),
isConfigured: () => true,
},
status: {},
} as never;
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]);
expect(
(await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"),
).toMatchObject({ configured: true, state: "configured" });
});
it("does not treat an incomplete Slack account as configured when a required token is missing", async () => {
const account = {
accountId: "default",
enabled: true,
configured: false,
botTokenStatus: "configured_unavailable" as const,
appTokenStatus: "missing" as const,
userTokenStatus: "missing" as const,
};
const plugin = {
id: "slack",
meta: { label: "Slack" },
config: {
listAccountIds: () => ["default"],
inspectAccount: () => account,
resolveAccount: () => account,
describeAccount: () => ({ accountId: "default", enabled: true, configured: true }),
isConfigured: () => false,
},
status: {},
} as never;
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]);
expect(
(await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"),
).toMatchObject({ configured: false, state: "not configured" });
});
it("keeps a fully configured Slack account visible when a required token is unavailable", async () => {
const account = {
accountId: "default",
enabled: true,
configured: true,
botTokenStatus: "configured_unavailable" as const,
appTokenStatus: "available" as const,
userTokenStatus: "missing" as const,
};
const plugin = {
id: "slack",
meta: { label: "Slack" },
config: {
listAccountIds: () => ["default"],
inspectAccount: () => account,
resolveAccount: () => account,
describeAccount: () => ({ accountId: "default", enabled: true, configured: false }),
isConfigured: () => false,
},
status: {},
} as never;
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]);
expect(
(await buildProviderStatusIndex({} as OpenClawConfig)).get("slack:default"),
).toMatchObject({ configured: true, state: "configured unavailable" });
});
it("does not inspect linkage for an unconfigured account", async () => {
+36 -12
View File
@@ -1,5 +1,6 @@
// Provider/account summary helpers for `openclaw agents list`.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { hasConfiguredUnavailableCredentialStatus } from "../channels/account-snapshot-fields.js";
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
@@ -22,7 +23,14 @@ type ProviderAccountStatus = {
providerLabel?: string;
accountId: string;
name?: string;
state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled";
state:
| "linked"
| "not linked"
| "configured"
| "configured unavailable"
| "not configured"
| "enabled"
| "disabled";
enabled?: boolean;
configured?: boolean;
visibleInConfiguredLists?: boolean;
@@ -155,9 +163,11 @@ export async function buildProviderStatusIndex(
}
map.set(providerAccountKey(plugin.id, accountId), {
provider: plugin.id,
providerLabel: plugin.meta.label,
accountId,
state: "not configured",
configured: false,
state: "configured unavailable",
configured: true,
visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta),
});
continue;
}
@@ -175,6 +185,18 @@ export async function buildProviderStatusIndex(
: snapshot?.configured;
const resolvedEnabled = typeof enabled === "boolean" ? enabled : true;
const resolvedConfigured = typeof configured === "boolean" ? configured : true;
const inspectedConfigured = (account as { configured?: unknown }).configured;
const configuredIntent =
typeof inspectedConfigured === "boolean"
? inspectedConfigured
: snapshot?.configured === true;
// Provider inspection owns which credentials are required. Only an account whose owner
// reports complete configured intent but no usable runtime credentials is unavailable.
const configuredUnavailable =
!resolvedConfigured &&
configuredIntent &&
(hasConfiguredUnavailableCredentialStatus(snapshot) ||
hasConfiguredUnavailableCredentialStatus(account));
const linkState =
resolvedConfigured && plugin.config.isLinked
? await plugin.config.isLinked(account, cfg)
@@ -186,14 +208,16 @@ export async function buildProviderStatusIndex(
configured: resolvedConfigured,
enabled: resolvedEnabled,
});
const state = projectChannelAccountDisplayState(
resolveChannelAccountState({
enabled: resolvedEnabled,
configured: resolvedConfigured,
linked,
}),
fallbackState,
);
const state = configuredUnavailable
? "configured unavailable"
: projectChannelAccountDisplayState(
resolveChannelAccountState({
enabled: resolvedEnabled,
configured: resolvedConfigured,
linked,
}),
fallbackState,
);
const name = snapshot?.name ?? (account as { name?: string }).name;
map.set(providerAccountKey(plugin.id, accountId), {
provider: plugin.id,
@@ -202,7 +226,7 @@ export async function buildProviderStatusIndex(
name,
state,
enabled,
configured,
configured: configuredUnavailable || configured,
visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta),
});
}
+3 -3
View File
@@ -153,11 +153,11 @@ describe("status-json-runtime", () => {
expect(payloadInput.pluginCompatibility).toBeUndefined();
});
it("suppresses health errors when requested", async () => {
it("preserves failed deep health probes in nonthrowing JSON output", async () => {
mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({
securityAudit: undefined,
usage: undefined,
health: undefined,
health: { error: "gateway health probe timed out" },
lastHeartbeat: { status: "ok" },
gatewayService: { label: "LaunchAgent" },
nodeService: { label: "node" },
@@ -173,7 +173,7 @@ describe("status-json-runtime", () => {
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledOnce();
const payloadInput = requireStatusPayloadInput();
expect(payloadInput.surface.gatewayProbeAuth).toStrictEqual({ token: "tok" });
expect(payloadInput.health).toBeUndefined();
expect(payloadInput.health).toEqual({ error: "gateway health probe timed out" });
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
config: { update: { channel: "stable" }, gateway: {} },
sourceConfig: { gateway: {} },
+39 -1
View File
@@ -144,8 +144,18 @@ describe("statusJsonCommand", () => {
expect(payload).not.toHaveProperty("securityAudit");
});
it("includes security audit details only when --all is requested", async () => {
it("includes security audit and plugin compatibility details when --all is requested", async () => {
const { runtime, logs } = createRuntimeCapture();
const compatibilityNotice = {
pluginId: "legacy-plugin",
code: "hook-only",
severity: "warn",
message: "plugin registers only legacy hooks",
};
mocks.scanStatusJsonFast.mockResolvedValueOnce({
...createScanResult(),
pluginCompatibility: [compatibilityNotice],
});
await statusJsonCommand({ all: true }, runtime);
@@ -183,6 +193,34 @@ describe("statusJsonCommand", () => {
summary: { critical: 1, warn: 0, info: 0 },
findings: [],
},
pluginCompatibility: {
count: 1,
warnings: [compatibilityNotice],
},
});
});
it("reports deep gateway probe failures and runs the documented security audit", async () => {
const { runtime, logs } = createRuntimeCapture();
mocks.scanStatusJsonFast.mockResolvedValueOnce({
...createScanResult(),
gatewayReachable: true,
});
mocks.callGateway.mockImplementation(async (params: { method?: string }) => {
if (params.method === "health") {
throw new Error("gateway health probe timed out");
}
return null;
});
await statusJsonCommand({ deep: true }, runtime);
expect(mocks.runSecurityAudit).toHaveBeenCalledOnce();
const payload = JSON.parse(logs[0] ?? "{}") as {
health?: { error?: string };
securityAudit?: { summary?: { critical?: number } };
};
expect(payload.health).toEqual({ error: "Error: gateway health probe timed out" });
expect(payload.securityAudit?.summary?.critical).toBe(1);
});
});
+2 -2
View File
@@ -19,8 +19,8 @@ export async function statusJsonCommand(
opts,
runtime,
scanStatusJsonFast,
// `--all` is the opt-in path for heavier security audit fields in JSON output.
includeSecurityAudit: opts.all === true,
includeSecurityAudit: opts.all === true || opts.deep === true,
includePluginCompatibility: opts.all === true,
suppressHealthErrors: true,
});
}
@@ -409,4 +409,34 @@ describe("status-runtime-shared", () => {
plugins: [{ id: "telegram" }],
});
});
it("keeps failed deep health probes visible in nonthrowing status snapshots", async () => {
mocks.callGateway.mockRejectedValueOnce(new Error("gateway health probe timed out"));
await expect(
resolveStatusRuntimeSnapshot({
config: { gateway: {} },
sourceConfig: { gateway: {} },
deep: true,
gatewayReachable: true,
suppressHealthErrors: true,
}),
).resolves.toMatchObject({
health: { error: "Error: gateway health probe timed out" },
lastHeartbeat: { ok: true },
});
});
it("does not suppress failed deep health probes for text status", async () => {
mocks.callGateway.mockRejectedValueOnce(new Error("gateway health probe timed out"));
await expect(
resolveStatusRuntimeSnapshot({
config: { gateway: {} },
sourceConfig: { gateway: {} },
deep: true,
gatewayReachable: true,
}),
).rejects.toThrow("gateway health probe timed out");
});
});
+5 -3
View File
@@ -235,6 +235,7 @@ export async function resolveStatusServiceSummaries(timeoutMs?: number) {
type StatusUsageSummary = Awaited<ReturnType<typeof resolveStatusUsageSummary>>;
type StatusGatewayHealth = Awaited<ReturnType<typeof resolveStatusGatewayHealth>>;
type StatusGatewayHealthResult = StatusGatewayHealth | { error: string };
type StatusLastHeartbeat = Awaited<ReturnType<typeof resolveStatusLastHeartbeat>>;
type StatusGatewayServiceSummary = Awaited<ReturnType<typeof getDaemonStatusSummary>>;
type StatusNodeServiceSummary = Awaited<ReturnType<typeof getNodeDaemonStatusSummary>>;
@@ -262,12 +263,13 @@ async function resolveStatusRuntimeDetails(params: {
config: params.config,
})
: undefined;
// JSON status remains nonthrowing, but requested probe failures must stay visible.
const health = params.deep
? params.suppressHealthErrors
? await resolveGatewayHealthSummary({
config: params.config,
timeoutMs: params.timeoutMs,
}).catch(() => undefined)
}).catch((error: unknown) => ({ error: String(error) }))
: await resolveGatewayHealthSummary({
config: params.config,
timeoutMs: params.timeoutMs,
@@ -291,7 +293,7 @@ async function resolveStatusRuntimeDetails(params: {
};
return result satisfies {
usage?: StatusUsageSummary;
health?: StatusGatewayHealth;
health?: StatusGatewayHealthResult;
lastHeartbeat: StatusLastHeartbeat;
gatewayService: StatusGatewayServiceSummary;
nodeService: StatusNodeServiceSummary;
@@ -342,7 +344,7 @@ export async function resolveStatusRuntimeSnapshot(params: {
} satisfies {
securityAudit?: StatusSecurityAudit;
usage?: StatusUsageSummary;
health?: StatusGatewayHealth;
health?: StatusGatewayHealthResult;
lastHeartbeat: StatusLastHeartbeat;
gatewayService: StatusGatewayServiceSummary;
nodeService: StatusNodeServiceSummary;
+7 -2
View File
@@ -141,8 +141,8 @@ export async function statusCommand(
await runStatusJsonCommand({
opts,
runtime,
includeSecurityAudit: opts.all === true,
includePluginCompatibility: true,
includeSecurityAudit: opts.all === true || opts.deep === true,
includePluginCompatibility: opts.all === true,
suppressHealthErrors: true,
scanStatusJsonFast: async (scanOpts, runtimeForScan) =>
await loadStatusScanFastJsonModule().then(({ scanStatusJsonFast }) =>
@@ -226,6 +226,11 @@ export async function statusCommand(
),
});
// Structured probe failures belong to nonthrowing JSON; text status keeps failures loud.
if (health && "error" in health) {
throw new Error(health.error);
}
const rich = true;
const {
buildStatusUpdateSurface,
@@ -135,6 +135,23 @@ describe("scanStatusJsonFast", () => {
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
});
it("collects actual plugin compatibility warnings when full JSON status is requested", async () => {
const notice = {
pluginId: "legacy-plugin",
code: "hook-only",
severity: "warn",
message: "plugin registers only legacy hooks",
};
mocks.buildPluginCompatibilityNotices.mockReturnValue([notice]);
const result = await scanStatusJsonFast({ all: true }, {} as never);
expect(mocks.buildPluginCompatibilityNotices).toHaveBeenCalledWith({
config: createStatusMemorySearchConfig(),
});
expect(result.pluginCompatibility).toEqual([notice]);
});
it("keeps default fast JSON update scans local-only", async () => {
mocks.hasConfiguredChannels.mockReturnValue(true);
+11 -1
View File
@@ -13,6 +13,9 @@ import type { StatusScanResult } from "./status.scan-result.ts";
const statusScanMemoryModuleLoader = createLazyImportLoader(
() => import("./status.scan-memory.js"),
);
const statusScanPluginStatusModuleLoader = createLazyImportLoader(
() => import("../plugins/status.js"),
);
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
const STATUS_JSON_CHANNEL_ENV_PREFIXES = GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter(
@@ -105,13 +108,20 @@ export async function scanStatusJsonWithPolicy(
includeLocalStatusRpcFallback: policy.includeLocalStatusRpcFallback,
gatewayProbeTimeoutMs: policy.gatewayProbeTimeoutMs,
});
const pluginCompatibility = opts.all
? await statusScanPluginStatusModuleLoader
.load()
.then(({ buildPluginCompatibilitySnapshotNotices }) =>
buildPluginCompatibilitySnapshotNotices({ config: overview.cfg }),
)
: [];
return await executeStatusScanFromOverview({
overview,
runtime,
resolveMemory: policy.resolveMemory,
channelIssues: [],
channels: { rows: [], details: [] },
pluginCompatibility: [],
pluginCompatibility,
});
}
+15 -7
View File
@@ -767,8 +767,8 @@ vi.mock("../plugins/status.js", () => ({
}));
vi.mock("./status.scan.fast-json.js", () => ({
scanStatusJsonFast: vi.fn(async () =>
createMockStatusScanResult({ includePluginCompatibility: false }),
scanStatusJsonFast: vi.fn(async (opts: { all?: boolean }) =>
createMockStatusScanResult({ includePluginCompatibility: opts.all === true }),
),
}));
@@ -1011,7 +1011,7 @@ describe("statusCommand", () => {
(runtime.error as Mock<(...args: unknown[]) => void>).mockClear();
});
it("prints JSON and includes security audit only when all is requested", async () => {
it("prints JSON and includes full diagnostics only when all is requested", async () => {
mocks.buildPluginCompatibilityNotices.mockReturnValue([
createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" }),
]);
@@ -1034,10 +1034,7 @@ describe("statusCommand", () => {
expect(payload.securityAudit).toBeUndefined();
expect(payload.gatewayService.label).toBe("LaunchAgent");
expect(payload.nodeService.label).toBe("LaunchAgent");
expect(payload.pluginCompatibility).toEqual({
count: 0,
warnings: [],
});
expect(payload.pluginCompatibility).toBeUndefined();
expect(payload.tasks.total).toBe(0);
expect(payload.tasks.active).toBe(0);
expect(payload.tasks.byStatus.queued).toBe(0);
@@ -1050,6 +1047,10 @@ describe("statusCommand", () => {
const allPayload = JSON.parse(getRuntimeLog(0));
expect(allPayload.securityAudit.summary.critical).toBe(1);
expect(allPayload.securityAudit.summary.warn).toBe(1);
expect(allPayload.pluginCompatibility).toEqual({
count: 1,
warnings: [createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" })],
});
const auditParams = mocks.runSecurityAudit.mock.calls[0]?.[0];
expect(auditParams?.includeFilesystem).toBe(true);
expect(auditParams?.includeChannelSecurity).toBe(true);
@@ -1092,6 +1093,13 @@ describe("statusCommand", () => {
expect(mocks.runSecurityAudit).not.toHaveBeenCalled();
});
it("includes the security audit for deep JSON status", async () => {
await statusCommand({ json: true, deep: true }, runtime as never);
expect(mocks.runSecurityAudit).toHaveBeenCalledOnce();
expect(JSON.parse(getRuntimeLog(0)).securityAudit.summary.critical).toBe(1);
});
it("passes deep mode through to the text status scan", async () => {
const { scanStatus } = await import("./status.scan.js");
vi.mocked(scanStatus).mockClear();
@@ -5,6 +5,7 @@ import { afterAll, afterEach, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import {
cleanupPluginLoaderFixturesForTest,
loadOpenClawPlugins,
makeTempDir,
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
@@ -66,4 +67,38 @@ describe("plugin compatibility snapshot notices", () => {
expect(buildSnapshotCompatibilityNoticeCodes(plugin)).toStrictEqual([]);
});
it("reports actual hook-only registrations without activating cold plugin modules", () => {
const pluginDir = makeTempDir();
const runtimeMarker = path.join(pluginDir, "runtime-loaded");
const plugin = writePlugin({
id: "runtime-hook-only",
dir: pluginDir,
body: `module.exports = { id: "runtime-hook-only", register(api) { require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded"); api.on("message_received", () => {}); } };\n`,
});
const stateDir = makeTempDir();
const config = {
plugins: {
load: { paths: [plugin.file] },
allow: [plugin.id],
},
};
withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => {
useNoBundledPlugins();
const params = { config, workspaceDir: plugin.dir, env: process.env };
expect(buildPluginCompatibilitySnapshotNotices(params)).toStrictEqual([]);
expect(fs.existsSync(runtimeMarker)).toBe(false);
const registry = loadOpenClawPlugins({ ...params, cache: false });
expect(fs.existsSync(runtimeMarker)).toBe(true);
expect(registry.typedHooks).toEqual([
expect.objectContaining({ pluginId: plugin.id, hookName: "message_received" }),
]);
expect(buildPluginCompatibilitySnapshotNotices(params)).toEqual([
expect.objectContaining({ pluginId: plugin.id, code: "hook-only" }),
]);
});
});
});
+41
View File
@@ -15,6 +15,7 @@ import {
const loadConfigMock = vi.fn();
const loadOpenClawPluginsMock = vi.fn();
const resolveCompatibleRuntimePluginRegistryMock = vi.fn();
const loadPluginMetadataRegistrySnapshotMock = vi.fn();
const loadPluginManifestRegistryForPluginRegistryMock = vi.fn();
const loadPluginRegistrySnapshotWithMetadataMock = vi.fn();
@@ -47,6 +48,7 @@ let buildPluginDiagnosticsReport: typeof import("./status.js").buildPluginDiagno
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
let buildPluginCompatibilitySnapshotNotices: typeof import("./status.js").buildPluginCompatibilitySnapshotNotices;
let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings;
let formatPluginCompatibilityNotice: typeof import("./status.js").formatPluginCompatibilityNotice;
let summarizePluginCompatibility: typeof import("./status.js").summarizePluginCompatibility;
@@ -62,6 +64,8 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
resolveCompatibleRuntimePluginRegistry: (...args: unknown[]) =>
resolveCompatibleRuntimePluginRegistryMock(...args),
}));
vi.mock("./runtime/metadata-registry-loader.js", () => ({
@@ -385,6 +389,7 @@ describe("plugin status reports", () => {
({
buildAllPluginInspectReports,
buildPluginCompatibilityNotices,
buildPluginCompatibilitySnapshotNotices,
buildPluginDiagnosticsReport,
buildPluginCompatibilityWarnings,
buildPluginInspectReport,
@@ -397,6 +402,7 @@ describe("plugin status reports", () => {
beforeEach(() => {
loadConfigMock.mockReset();
loadOpenClawPluginsMock.mockReset();
resolveCompatibleRuntimePluginRegistryMock.mockReset();
loadPluginMetadataRegistrySnapshotMock.mockReset();
loadPluginManifestRegistryForPluginRegistryMock.mockReset();
loadPluginRegistrySnapshotWithMetadataMock.mockReset();
@@ -791,6 +797,41 @@ describe("plugin status reports", () => {
});
});
it("reuses compatible runtime hook registrations without loading cold plugin modules", () => {
const metadataPlugin = createPluginRecord({
id: "runtime-hook-only",
name: "Runtime Hook Only",
});
const runtimePlugin = createPluginRecord({
id: "runtime-hook-only",
name: "Runtime Hook Only",
hookCount: 1,
});
setSinglePluginLoadResult(metadataPlugin);
resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(
createPluginLoadResult({
plugins: [runtimePlugin],
hooks: [createCustomHook({ pluginId: runtimePlugin.id, events: ["message"] })],
}),
);
expect(buildPluginCompatibilitySnapshotNotices({ config: {} })).toEqual([
createCompatibilityNotice({ pluginId: runtimePlugin.id, code: "hook-only" }),
]);
expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledOnce();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not claim hook-only warnings from an unloaded metadata-only plugin", () => {
setSinglePluginLoadResult(
createPluginRecord({ id: "cold-plugin", name: "Cold Plugin", hookCount: 0 }),
);
resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined);
expect(buildPluginCompatibilitySnapshotNotices({ config: {} })).toStrictEqual([]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("warns external plugins off deprecated memory embedding provider registration", () => {
setSinglePluginLoadResult(
createPluginRecord({
+21 -2
View File
@@ -20,7 +20,7 @@ import {
type PluginCapabilityEntry,
type PluginInspectShape,
} from "./inspect-shape.js";
import { loadOpenClawPlugins } from "./loader.js";
import { loadOpenClawPlugins, resolveCompatibleRuntimePluginRegistry } from "./loader.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
@@ -548,9 +548,28 @@ export function buildPluginCompatibilitySnapshotNotices(params?: {
env?: NodeJS.ProcessEnv;
}): PluginCompatibilityNotice[] {
const report = buildPluginSnapshotReport(params);
const context = resolvePluginRuntimeLoadContext(params);
const runtimeRegistry = resolveCompatibleRuntimePluginRegistry(
buildPluginRuntimeLoadOptions(context, { activate: false }),
);
const registeredPlugins = new Map(runtimeRegistry?.plugins.map((plugin) => [plugin.id, plugin]));
// Hook shape is a runtime registration fact. Reuse compatible live registrations without
// importing cold plugins or guessing their capabilities from a manifest-only snapshot.
const registrationReport = runtimeRegistry
? {
...report,
...runtimeRegistry,
workspaceDir: report.workspaceDir,
plugins: report.plugins.map((plugin) => ({
...plugin,
...registeredPlugins.get(plugin.id),
imported: plugin.imported,
})),
}
: report;
return buildPluginCompatibilityNotices({
...params,
report,
report: registrationReport,
});
}