diff --git a/extensions/slack/src/account-inspect.test.ts b/extensions/slack/src/account-inspect.test.ts index f9724ce6973f..038d3c015e1f 100644 --- a/extensions/slack/src/account-inspect.test.ts +++ b/extensions/slack/src/account-inspect.test.ts @@ -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): 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); }); }); diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index 3130969c735a..2dcb885bef52 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -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 () => { diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 8df35b093b74..3bf7ef3557f9 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -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), }); } diff --git a/src/commands/status-json-runtime.test.ts b/src/commands/status-json-runtime.test.ts index 42c9009db352..d9f60d218908 100644 --- a/src/commands/status-json-runtime.test.ts +++ b/src/commands/status-json-runtime.test.ts @@ -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: {} }, diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts index 402a79a2825d..d43ed811ab59 100644 --- a/src/commands/status-json.test.ts +++ b/src/commands/status-json.test.ts @@ -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); + }); }); diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index a23654631623..a6b4ceed116b 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -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, }); } diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 7110a40c1d8f..1760b9abc06f 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -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"); + }); }); diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 9f003eb47dd4..fcfc62ee1a58 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -235,6 +235,7 @@ export async function resolveStatusServiceSummaries(timeoutMs?: number) { type StatusUsageSummary = Awaited>; type StatusGatewayHealth = Awaited>; +type StatusGatewayHealthResult = StatusGatewayHealth | { error: string }; type StatusLastHeartbeat = Awaited>; type StatusGatewayServiceSummary = Awaited>; type StatusNodeServiceSummary = Awaited>; @@ -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; diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 9dc24ca75eb0..9e2a26bbcd99 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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, diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 526977006122..0616e502194c 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -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); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 6b8b0b3292d0..93f18b2bd979 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -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, }); } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a46981581dec..3eec60f21ce6 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -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(); diff --git a/src/plugins/status.compatibility.integration.test.ts b/src/plugins/status.compatibility.integration.test.ts index 4ec6d95ce08a..9eaaf42c4af5 100644 --- a/src/plugins/status.compatibility.integration.test.ts +++ b/src/plugins/status.compatibility.integration.test.ts @@ -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" }), + ]); + }); + }); }); diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 99f4b7753252..5a38a4c0aa9c 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -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({ diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 440fcd1244f3..69f9b9195f1d 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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, }); }