From 03c4bacbfb46c96eb02517d79dc7e9fabf62522c Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Sun, 22 Mar 2026 16:31:57 -0500 Subject: [PATCH] fix(cli): route deferred plugin logs to stderr in status --json --- src/commands/status.scan.fast-json.test.ts | 26 +++++++++++++++++++++- src/commands/status.scan.fast-json.ts | 10 ++++++++- src/commands/status.scan.test.ts | 13 ++++++++++- src/commands/status.scan.ts | 10 ++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 5fa96ec647b..e6ce1cace9d 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loggingState } from "../logging/state.js"; const mocks = vi.hoisted(() => ({ resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-fast-json-missing-${process.pid}.json`), @@ -18,8 +19,12 @@ const mocks = vi.hoisted(() => ({ buildPluginCompatibilityNotices: vi.fn(() => []), })); +let originalForceStderr: boolean; + beforeEach(() => { vi.clearAllMocks(); + originalForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; mocks.hasPotentialConfiguredChannels.mockReturnValue(false); mocks.readBestEffortConfig.mockResolvedValue({ session: {}, @@ -170,7 +175,26 @@ vi.mock("../plugins/status.js", () => ({ const { scanStatusJsonFast } = await import("./status.scan.fast-json.js"); +afterEach(() => { + loggingState.forceConsoleToStderr = originalForceStderr; +}); + describe("scanStatusJsonFast", () => { + it("routes plugin logs to stderr during deferred plugin loading", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); + + let stderrDuringLoad = false; + mocks.ensurePluginRegistryLoaded.mockImplementation(() => { + stderrDuringLoad = loggingState.forceConsoleToStderr; + }); + + await scanStatusJsonFast({}, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalled(); + expect(stderrDuringLoad).toBe(true); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + 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 4b4ac962d0e..c1f069518f6 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -5,6 +5,7 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveOsSummary } from "../infra/os-summary.js"; +import { loggingState } from "../logging/state.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; @@ -159,7 +160,14 @@ export async function scanStatusJsonFast( const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg); if (hasConfiguredChannels) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "configured-channels" }); + // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. + const prev = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = true; + try { + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + } finally { + loggingState.forceConsoleToStderr = prev; + } } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index ab54911dad6..fe77d8bb66c 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loggingState } from "../logging/state.js"; const mocks = vi.hoisted(() => ({ resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-scan-missing-${process.pid}.json`), @@ -18,11 +19,19 @@ const mocks = vi.hoisted(() => ({ buildPluginCompatibilityNotices: vi.fn(() => []), })); +let originalForceStderr: boolean; + beforeEach(() => { vi.clearAllMocks(); + originalForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; mocks.hasPotentialConfiguredChannels.mockReturnValue(false); }); +afterEach(() => { + loggingState.forceConsoleToStderr = originalForceStderr; +}); + vi.mock("../channels/config-presence.js", () => ({ hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, })); @@ -503,6 +512,8 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "configured-channels", }); + // Verify plugin logs were routed to stderr during loading and restored after + expect(loggingState.forceConsoleToStderr).toBe(false); expect(mocks.probeGateway).toHaveBeenCalledWith( expect.objectContaining({ detailLevel: "presence" }), ); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 6b815361f68..e281e94e227 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -10,6 +10,7 @@ import { resolveConfigPath } from "../config/paths.js"; import { callGateway } from "../gateway/call.js"; import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; +import { loggingState } from "../logging/state.js"; import { buildPluginCompatibilityNotices, type PluginCompatibilityNotice, @@ -166,7 +167,14 @@ async function scanStatusJsonFast(opts: { const hasConfiguredChannels = hasPotentialConfiguredChannels(cfg); if (hasConfiguredChannels) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "configured-channels" }); + // Route plugin registration logs to stderr so they don't corrupt JSON on stdout. + const prev = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = true; + try { + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + } finally { + loggingState.forceConsoleToStderr = prev; + } } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";