Skip to content

Commit 97e4f37

Browse files
committed
fix: keep status --json stdout clean (#52449) (thanks @cgdusek)
1 parent 03c4bac commit 97e4f37

7 files changed

Lines changed: 72 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai
115115
- 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.
116116
- 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.
117117
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
118+
- CLI/status: keep `status --json` stdout clean by skipping plugin compatibility scans that were not rendered in the JSON payload. (#52449) Thanks @cgdusek.
118119
- 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.
119120
- 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.
120121
- 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.

src/cli/program/preaction.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,8 @@ describe("registerPreActionHooks", () => {
352352
});
353353

354354
await runPreAction({
355-
parseArgv: ["agents"],
356-
processArgv: ["node", "openclaw", "agents", "--json"],
355+
parseArgv: ["agents", "list"],
356+
processArgv: ["node", "openclaw", "agents", "list", "--json"],
357357
});
358358

359359
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
@@ -369,8 +369,8 @@ describe("registerPreActionHooks", () => {
369369
});
370370

371371
await runPreAction({
372-
parseArgv: ["agents"],
373-
processArgv: ["node", "openclaw", "agents"],
372+
parseArgv: ["agents", "list"],
373+
processArgv: ["node", "openclaw", "agents", "list"],
374374
});
375375

376376
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();

src/cli/program/preaction.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ export function registerPreActionHooks(program: Command, programVersion: string)
139139
commandPath,
140140
...(jsonOutputMode ? { suppressDoctorStdout: true } : {}),
141141
});
142-
<<<<<<< HEAD
143142
// Load plugins for commands that need channel access.
144143
// When --json output is active, temporarily route logs to stderr so plugin
145144
// registration messages don't corrupt the JSON payload on stdout.

src/commands/status.scan.fast-json.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ describe("scanStatusJsonFast", () => {
195195
expect(loggingState.forceConsoleToStderr).toBe(false);
196196
});
197197

198+
it("skips plugin compatibility loading even when configured channels are present", async () => {
199+
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
200+
201+
await scanStatusJsonFast({}, {} as never);
202+
203+
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
204+
});
205+
198206
it("skips memory inspection for the lean status --json fast path", async () => {
199207
const result = await scanStatusJsonFast({}, {} as never);
200208

src/commands/status.scan.fast-json.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { getStatusSummary } from "./status.summary.js";
2323
import { getUpdateCheckResult } from "./status.update.js";
2424

2525
let pluginRegistryModulePromise: Promise<typeof import("../cli/plugin-registry.js")> | undefined;
26-
let pluginStatusModulePromise: Promise<typeof import("../plugins/status.js")> | undefined;
2726
let configIoModulePromise: Promise<typeof import("../config/io.js")> | undefined;
2827
let commandSecretTargetsModulePromise:
2928
| Promise<typeof import("../cli/command-secret-targets.js")>
@@ -41,11 +40,6 @@ function loadPluginRegistryModule() {
4140
return pluginRegistryModulePromise;
4241
}
4342

44-
function loadPluginStatusModule() {
45-
pluginStatusModulePromise ??= import("../plugins/status.js");
46-
return pluginStatusModulePromise;
47-
}
48-
4943
function loadConfigIoModule() {
5044
configIoModulePromise ??= import("../config/io.js");
5145
return configIoModulePromise;
@@ -91,13 +85,6 @@ function buildColdStartUpdateResult(): Awaited<ReturnType<typeof getUpdateCheckR
9185
};
9286
}
9387

94-
function shouldCollectPluginCompatibility(cfg: OpenClawConfig): boolean {
95-
if (hasPotentialConfiguredChannels(cfg)) {
96-
return true;
97-
}
98-
return existsSync(resolveConfigPath(process.env));
99-
}
100-
10188
function resolveDefaultMemoryStorePath(agentId: string): string {
10289
return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`);
10390
}
@@ -233,14 +220,9 @@ export async function scanStatusJsonFast(
233220
const memory = opts.all
234221
? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin })
235222
: null;
236-
const pluginCompatibility = shouldCollectPluginCompatibility(cfg)
237-
? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) =>
238-
// Keep plugin status loading off the empty-config `status --json` fast path.
239-
// The plugin status module pulls in the full loader graph and materially bloats
240-
// startup RSS even when plugin compatibility is never consulted.
241-
buildPluginCompatibilityNotices({ config: cfg }),
242-
)
243-
: [];
223+
// `status --json` does not serialize plugin compatibility notices, so keep the
224+
// fast path off the full plugin status graph after the initial scoped preload.
225+
const pluginCompatibility: StatusScanResult["pluginCompatibility"] = [];
244226

245227
return {
246228
cfg,

src/commands/status.scan.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,59 @@ describe("scanStatus", () => {
284284
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
285285
});
286286

287+
it("skips plugin compatibility loading for status --json even with configured channels", async () => {
288+
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
289+
mocks.readBestEffortConfig.mockResolvedValue({
290+
session: {},
291+
gateway: {},
292+
channels: { discord: {} },
293+
});
294+
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
295+
resolvedConfig: {
296+
session: {},
297+
gateway: {},
298+
channels: { discord: {} },
299+
},
300+
diagnostics: [],
301+
});
302+
mocks.getUpdateCheckResult.mockResolvedValue({
303+
installKind: "git",
304+
git: null,
305+
registry: null,
306+
});
307+
mocks.getAgentLocalStatuses.mockResolvedValue({
308+
defaultId: "main",
309+
agents: [],
310+
});
311+
mocks.getStatusSummary.mockResolvedValue({
312+
linkChannel: undefined,
313+
sessions: { count: 0, paths: [], defaults: {}, recent: [] },
314+
});
315+
mocks.buildGatewayConnectionDetails.mockReturnValue({
316+
url: "ws://127.0.0.1:18789",
317+
urlSource: "default",
318+
});
319+
mocks.resolveGatewayProbeAuthResolution.mockResolvedValue({
320+
auth: {},
321+
warning: undefined,
322+
});
323+
mocks.probeGateway.mockResolvedValue({
324+
ok: false,
325+
url: "ws://127.0.0.1:18789",
326+
connectLatencyMs: null,
327+
error: "timeout",
328+
close: null,
329+
health: null,
330+
status: null,
331+
presence: null,
332+
configSnapshot: null,
333+
});
334+
335+
await scanStatus({ json: true }, {} as never);
336+
337+
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
338+
});
339+
287340
it("skips gateway and update probes on cold-start status paths", async () => {
288341
mocks.readBestEffortConfig.mockResolvedValue({
289342
session: {},

src/commands/status.scan.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,6 @@ function unwrapDeferredResult<T>(result: DeferredResult<T>): T {
6969
return result.value;
7070
}
7171

72-
function shouldCollectPluginCompatibility(cfg: OpenClawConfig): boolean {
73-
if (hasPotentialConfiguredChannels(cfg)) {
74-
return true;
75-
}
76-
return existsSync(resolveConfigPath(process.env));
77-
}
78-
7972
function isMissingConfigColdStart(): boolean {
8073
return !existsSync(resolveConfigPath(process.env));
8174
}
@@ -237,9 +230,9 @@ async function scanStatusJsonFast(opts: {
237230
const memoryPlugin = resolveMemoryPluginStatus(cfg);
238231
const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
239232
const memory = await memoryPromise;
240-
const pluginCompatibility = shouldCollectPluginCompatibility(cfg)
241-
? buildPluginCompatibilityNotices({ config: cfg })
242-
: [];
233+
// `status --json` never renders plugin compatibility notices, so skip the
234+
// full compatibility scan and avoid a second plugin load on the JSON path.
235+
const pluginCompatibility: StatusScanResult["pluginCompatibility"] = [];
243236

244237
return {
245238
cfg,

0 commit comments

Comments
 (0)