fix: keep status --json stdout clean (#52449) (thanks @cgdusek)

This commit is contained in:
Peter Steinberger
2026-03-22 21:49:24 -07:00
parent 03c4bacbfb
commit 97e4f37171
7 changed files with 72 additions and 36 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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.

View File

@@ -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);

View File

@@ -23,7 +23,6 @@ import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js";
let pluginRegistryModulePromise: Promise<typeof import("../cli/plugin-registry.js")> | undefined;
let pluginStatusModulePromise: Promise<typeof import("../plugins/status.js")> | undefined;
let configIoModulePromise: Promise<typeof import("../config/io.js")> | undefined;
let commandSecretTargetsModulePromise:
| Promise<typeof import("../cli/command-secret-targets.js")>
@@ -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<ReturnType<typeof getUpdateCheckR
};
}
function shouldCollectPluginCompatibility(cfg: OpenClawConfig): boolean {
if (hasPotentialConfiguredChannels(cfg)) {
return true;
}
return existsSync(resolveConfigPath(process.env));
}
function resolveDefaultMemoryStorePath(agentId: string): string {
return path.join(resolveStateDir(process.env, os.homedir), "memory", `${agentId}.sqlite`);
}
@@ -233,14 +220,9 @@ export async function scanStatusJsonFast(
const memory = opts.all
? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin })
: null;
const pluginCompatibility = shouldCollectPluginCompatibility(cfg)
? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) =>
// 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,

View File

@@ -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: {},

View File

@@ -69,13 +69,6 @@ function unwrapDeferredResult<T>(result: DeferredResult<T>): 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,