diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f704279ff..0b1abef4951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai - CLI/configure: clarify fresh-setup memory-search warnings so they say semantic recall needs at least one embedding provider, and scope the initial model allowlist picker to the provider selected in configure. Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. +- CLI/status: keep `status --json` stdout clean by skipping plugin compatibility scans that were not rendered in the JSON payload. (#52449) Thanks @cgdusek. - Agents/Telegram: avoid rebuilding the full model catalog on ordinary inbound replies so Telegram message handling no longer pays multi-second core startup latency before reply generation. Thanks @vincentkoc. - Gateway/Discord startup: load only configured channel plugins during gateway boot, and lazy-load Discord provider/session runtime setup so startup stops importing unrelated providers and trims cold-start delay. Thanks @vincentkoc. - Security/exec: harden macOS allowlist resolution against wrapper and `env` spoofing, require fresh approval for inline interpreter eval with `tools.exec.strictInlineEval`, wrap Discord guild message bodies as untrusted external content, and add audit findings for risky exec approval and open-channel combinations. diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 9dde0408b32..0fecac4ea0a 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -352,8 +352,8 @@ describe("registerPreActionHooks", () => { }); await runPreAction({ - parseArgv: ["agents"], - processArgv: ["node", "openclaw", "agents", "--json"], + parseArgv: ["agents", "list"], + processArgv: ["node", "openclaw", "agents", "list", "--json"], }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); @@ -369,8 +369,8 @@ describe("registerPreActionHooks", () => { }); await runPreAction({ - parseArgv: ["agents"], - processArgv: ["node", "openclaw", "agents"], + parseArgv: ["agents", "list"], + processArgv: ["node", "openclaw", "agents", "list"], }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 9d5e4fa910a..3cdbb2fe65c 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -139,7 +139,6 @@ export function registerPreActionHooks(program: Command, programVersion: string) commandPath, ...(jsonOutputMode ? { suppressDoctorStdout: true } : {}), }); -<<<<<<< HEAD // Load plugins for commands that need channel access. // When --json output is active, temporarily route logs to stderr so plugin // registration messages don't corrupt the JSON payload on stdout. diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index e6ce1cace9d..e0d3465fa80 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -195,6 +195,14 @@ describe("scanStatusJsonFast", () => { expect(loggingState.forceConsoleToStderr).toBe(false); }); + it("skips plugin compatibility loading even when configured channels are present", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); + + await scanStatusJsonFast({}, {} as never); + + expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled(); + }); + it("skips memory inspection for the lean status --json fast path", async () => { const result = await scanStatusJsonFast({}, {} as never); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index c1f069518f6..4c4a7fd50ce 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -23,7 +23,6 @@ import { getStatusSummary } from "./status.summary.js"; import { getUpdateCheckResult } from "./status.update.js"; let pluginRegistryModulePromise: Promise | undefined; -let pluginStatusModulePromise: Promise | undefined; let configIoModulePromise: Promise | undefined; let commandSecretTargetsModulePromise: | Promise @@ -41,11 +40,6 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } -function loadPluginStatusModule() { - pluginStatusModulePromise ??= import("../plugins/status.js"); - return pluginStatusModulePromise; -} - function loadConfigIoModule() { configIoModulePromise ??= import("../config/io.js"); return configIoModulePromise; @@ -91,13 +85,6 @@ function buildColdStartUpdateResult(): Awaited - // Keep plugin status loading off the empty-config `status --json` fast path. - // The plugin status module pulls in the full loader graph and materially bloats - // startup RSS even when plugin compatibility is never consulted. - buildPluginCompatibilityNotices({ config: cfg }), - ) - : []; + // `status --json` does not serialize plugin compatibility notices, so keep the + // fast path off the full plugin status graph after the initial scoped preload. + const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; return { cfg, diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index fe77d8bb66c..7b0cad9b2fa 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -284,6 +284,59 @@ describe("scanStatus", () => { expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled(); }); + it("skips plugin compatibility loading for status --json even with configured channels", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + channels: { discord: {} }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + channels: { discord: {} }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockResolvedValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled(); + }); + it("skips gateway and update probes on cold-start status paths", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index e281e94e227..76d4209ba0d 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -69,13 +69,6 @@ function unwrapDeferredResult(result: DeferredResult): T { return result.value; } -function shouldCollectPluginCompatibility(cfg: OpenClawConfig): boolean { - if (hasPotentialConfiguredChannels(cfg)) { - return true; - } - return existsSync(resolveConfigPath(process.env)); -} - function isMissingConfigColdStart(): boolean { return !existsSync(resolveConfigPath(process.env)); } @@ -237,9 +230,9 @@ async function scanStatusJsonFast(opts: { const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const memory = await memoryPromise; - const pluginCompatibility = shouldCollectPluginCompatibility(cfg) - ? buildPluginCompatibilityNotices({ config: cfg }) - : []; + // `status --json` never renders plugin compatibility notices, so skip the + // full compatibility scan and avoid a second plugin load on the JSON path. + const pluginCompatibility: StatusScanResult["pluginCompatibility"] = []; return { cfg,