fix(cli): route logs to stderr in --json mode#52465
Conversation
Greptile SummaryThis PR fixes a bug (#52032) where plugin-loading logs and other subsystem output would corrupt JSON on stdout when Key observations:
Confidence Score: 5/5
Reviews (1): Last reviewed commit: "fix(cli): route logs to stderr in --json..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 930f15ab92
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (isJsonOutputMode(commandPath, argv)) { | ||
| routeLogsToStderr(); |
There was a problem hiding this comment.
Avoid routing the actual
--json payload to stderr
Because defaultRuntime.log() is a thin wrapper around console.log() (src/runtime.ts:createRuntimeIo), calling routeLogsToStderr() here affects the entire command action, not just the pre-action/plugin-loading noise. Commands like health --json, channels list --json, and agents list --json emit their final JSON via runtime.log(JSON.stringify(...)), so after this change the payload goes to stderr and stdout is empty; existing openclaw … --json | jq pipelines will stop working. The redirect needs to be scoped so only incidental logs move off stdout.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch. enableConsoleCapture() patches console.log() before the preAction hook runs, so routeLogsToStderr() alone would redirect the JSON payload to stderr too.
Fixed in fac8703c3b: runtime.log() now writes directly to process.stdout when both forceConsoleToStderr and consolePatched are active, bypassing the patched console.log(). This keeps diagnostic/plugin logs on stderr while the JSON payload stays on stdout.
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🔵 Potential CPU denial-of-service from JSON.parse on every console line in --json log-routing
Description
Vulnerable code: if (loggingState.forceConsoleToStderr && !isJsonPayload(trimmed)) {
// ...
}
function isJsonPayload(value: string): boolean {
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}This can be used to degrade CLI responsiveness by emitting many large JSON-like lines while RecommendationReduce per-line overhead by avoiding (or bounding) JSON parsing in the console hot-path. Suggested mitigations (can be combined):
const isJson = loggingState.forceConsoleToStderr || loggingState.consoleTimestampPrefix
? isJsonPayload(trimmed)
: false;
const MAX_JSON_DETECT = 10_000;
if (trimmed.length > MAX_JSON_DETECT) return false;
Example bounded detector: function isJsonPayload(value: string): boolean {
const trimmed = value.trim();
if (trimmed.length > 10_000) return false;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if (!((first === '{' && last === '}') || (first === '[' && last === ']'))) return false;
try { JSON.parse(trimmed); return true; } catch { return false; }
}Analyzed PR: #52465 at commit Last updated on: 2026-03-22T23:29:28Z |
|
Re: Aisle security finding (CWE-74 — JSON output contamination) This is a valid observation about the broader What this PR does: The fix ensures that Why the Aisle concern doesn't apply here:
Follow-up opportunity: Aisle's suggestion to split |
933d0d8 to
99e31c2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 99e31c23e7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| if (loggingState.forceConsoleToStderr) { | ||
| // in RPC/JSON mode, keep stdout clean | ||
| if (loggingState.forceConsoleToStderr && !isJsonPayload(trimmed)) { |
There was a problem hiding this comment.
Preserve scalar JSON payloads when stderr routing is enabled
The new !isJsonPayload(trimmed) check only treats values starting with { or [ as the real JSON payload, so top-level JSON scalars still go down the stderr branch. That breaks --json commands whose legitimate output can be a string/number/bool/null — for example nodes canvas eval --json prints JSON.stringify(raw, null, 2) from src/cli/nodes-cli/register.canvas.ts:168-173, and raw is whatever the evaluated JavaScript returned. In those cases stdout becomes empty and automation no longer receives the payload.
Useful? React with 👍 / 👎.
Summary
routeLogsToStderr()in the CLI preAction hook when--jsonoutput mode is detected, so plugin-loading logs and other diagnostic output go to stderr instead of corrupting the JSON on stdout.forward()function to keep JSON payloads on stdout even whenforceConsoleToStderris active — so the actual command output (emitted viaruntime.log()) is not redirected.Fixes #52032
Root Cause
The preAction hook (
src/cli/program/preaction.ts) already hadisJsonOutputMode()androuteLogsToStderr()available but never connected them. When commands likeopenclaw agents list --jsonloaded plugins, their info-level logs went to stdout viaconsole.log(), breakingjq/ programmatic parsing.The
status/health --jsoncommands happened to work becauseshouldLoadPluginsForCommandskips plugin loading entirely for those two commands — but all other--jsoncommands (agents,channels,message,configure,directory) were affected.Approach
Two coordinated changes:
src/cli/program/preaction.ts: CallrouteLogsToStderr()early in the preAction hook whenisJsonOutputMode()is true — before banner, config-guard, or plugin loading can emit to stdout.src/logging/console.ts: In theforward()function (the patchedconsole.log()), whenforceConsoleToStderris true, checkisJsonPayload()first. If the message is a valid JSON payload (the actual command output), let it through to stdout via the originalconsole.log(). Only diagnostic/log messages get redirected to stderr.This avoids touching
runtime.ts(which is part of the plugin SDK API surface) and reuses the existingisJsonPayload()helper that was already used for timestamp-prefix suppression.Changes
src/cli/program/preaction.ts: ImportrouteLogsToStderrand call it whenisJsonOutputMode()is true.src/logging/console.ts: Add!isJsonPayload(trimmed)guard to theforceConsoleToStderrbranch so JSON payloads stay on stdout.src/cli/program/preaction.test.ts: Add regression test verifyingrouteLogsToStderris called foragents --json, is NOT called forconfig set --json(parse-only mode), and is NOT called without--json.Test plan
pnpm test -- src/cli/program/preaction.test.ts— 8/8 pass (including new test)pnpm test -- src/logging/— all passpnpm format:check— cleanpnpm plugin-sdk:api:check— no API baseline drift🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com