mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cli): make status and provider diagnostics complete (#116776)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
718e9c8820
commit
4e4f76f151
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user