Skip to content

fix(cli): route logs to stderr in --json mode#52465

Merged
steipete merged 3 commits intoopenclaw:mainfrom
cgdusek:claude/festive-lehmann
Mar 22, 2026
Merged

fix(cli): route logs to stderr in --json mode#52465
steipete merged 3 commits intoopenclaw:mainfrom
cgdusek:claude/festive-lehmann

Conversation

@cgdusek
Copy link
Copy Markdown
Contributor

@cgdusek cgdusek commented Mar 22, 2026

Summary

  • Calls routeLogsToStderr() in the CLI preAction hook when --json output mode is detected, so plugin-loading logs and other diagnostic output go to stderr instead of corrupting the JSON on stdout.
  • Modifies the console capture forward() function to keep JSON payloads on stdout even when forceConsoleToStderr is active — so the actual command output (emitted via runtime.log()) is not redirected.

Fixes #52032

Root Cause

The preAction hook (src/cli/program/preaction.ts) already had isJsonOutputMode() and routeLogsToStderr() available but never connected them. When commands like openclaw agents list --json loaded plugins, their info-level logs went to stdout via console.log(), breaking jq / programmatic parsing.

The status/health --json commands happened to work because shouldLoadPluginsForCommand skips plugin loading entirely for those two commands — but all other --json commands (agents, channels, message, configure, directory) were affected.

Approach

Two coordinated changes:

  1. src/cli/program/preaction.ts: Call routeLogsToStderr() early in the preAction hook when isJsonOutputMode() is true — before banner, config-guard, or plugin loading can emit to stdout.

  2. src/logging/console.ts: In the forward() function (the patched console.log()), when forceConsoleToStderr is true, check isJsonPayload() first. If the message is a valid JSON payload (the actual command output), let it through to stdout via the original console.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 existing isJsonPayload() helper that was already used for timestamp-prefix suppression.

Changes

  • src/cli/program/preaction.ts: Import routeLogsToStderr and call it when isJsonOutputMode() is true.
  • src/logging/console.ts: Add !isJsonPayload(trimmed) guard to the forceConsoleToStderr branch so JSON payloads stay on stdout.
  • src/cli/program/preaction.test.ts: Add regression test verifying routeLogsToStderr is called for agents --json, is NOT called for config 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 pass
  • pnpm format:check — clean
  • pnpm plugin-sdk:api:check — no API baseline drift
  • CI passes (channels/whatsapp failures are pre-existing mock issues on main)

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 22, 2026

Greptile Summary

This PR fixes a bug (#52032) where plugin-loading logs and other subsystem output would corrupt JSON on stdout when --json mode was used with commands like agents, channels, message, configure, or directory. The fix is minimal and correctly targeted: routeLogsToStderr() is called early in the preAction hook — before any banner emission, config guard, or plugin registry loading — ensuring loggingState.forceConsoleToStderr is set before any code that could emit to stdout.

Key observations:

  • The call is correctly placed before emitCliBanner, ensureConfigReady, and ensurePluginRegistryLoaded, so all three are covered by the redirect.
  • isJsonOutputMode is called twice (line 127 for the new redirect, line 150 for suppressDoctorStdout) — this is harmless since the function is trivially cheap, but could be extracted to a local variable for clarity in a follow-up.
  • The regression test covers all three important cases: a real JSON-output command, the config set --json parse-only exception, and a non-JSON invocation.
  • routeLogsToStderr() is idempotent (just sets a flag), so duplicate calls are safe.

Confidence Score: 5/5

  • This PR is safe to merge — the change is minimal, well-tested, and correctly placed within the existing hook lifecycle.
  • The fix is a one-liner connecting two already-present, already-tested utilities. The test coverage is solid (3 distinct scenarios), no existing tests are broken, and the implementation is idempotent. The only non-blocking observation is that isJsonOutputMode is evaluated twice per invocation, which is trivially cheap and does not affect correctness.
  • No files require special attention.

Reviews (1): Last reviewed commit: "fix(cli): route logs to stderr in --json..." | Re-trigger Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +127 to +128
if (isJsonOutputMode(commandPath, argv)) {
routeLogsToStderr();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 22, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🔵 Low Potential CPU denial-of-service from JSON.parse on every console line in --json log-routing

1. 🔵 Potential CPU denial-of-service from JSON.parse on every console line in --json log-routing

Property Value
Severity Low
CWE CWE-400
Location src/logging/console.ts:291-299

Description

enableConsoleCapture() now calls isJsonPayload(trimmed) while routing console output in --json mode:

  • Input: any console.* output (including plugin output and potentially echoed/untrusted data)
  • Hot path: forward() runs for every console line
  • Issue: isJsonPayload() performs JSON.parse(trimmed) inside a try/catch. For large strings (or many strings starting with {/[), this adds unbounded CPU work; for invalid JSON it also throws repeatedly (exception path is relatively expensive).
  • Amplifier: when timestamp prefixing is enabled, isJsonPayload(trimmed) may be evaluated multiple times per line (e.g., for timestamp decision and for stderr routing).

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 --json mode is active (e.g., via plugins or verbose libraries).

Recommendation

Reduce per-line overhead by avoiding (or bounding) JSON parsing in the console hot-path.

Suggested mitigations (can be combined):

  1. Cache the detection result per line so it isn't recomputed multiple times:
const isJson = loggingState.forceConsoleToStderr || loggingState.consoleTimestampPrefix
  ? isJsonPayload(trimmed)
  : false;
  1. Add a size limit before attempting JSON.parse:
const MAX_JSON_DETECT = 10_000;
if (trimmed.length > MAX_JSON_DETECT) return false;
  1. Prefer an explicit structured-output channel (bypass console capture) for command JSON output, e.g. write payloads with process.stdout.write() from a dedicated output function, or prefix command-output lines with a sentinel (so routing does not require parsing).

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 99e31c2

Last updated on: 2026-03-22T23:29:28Z

@cgdusek
Copy link
Copy Markdown
Contributor Author

cgdusek commented Mar 22, 2026

Re: Aisle security finding (CWE-74 — JSON output contamination)

This is a valid observation about the broader runtime.log surface, but it's out of scope for this PR and pre-existing behavior — not a regression introduced here.

What this PR does: The fix ensures that console.log() calls from plugin loading, banner emission, and config guards are routed to stderr when --json is active. The runtime.log() stdout bypass (writing directly to process.stdout when forceConsoleToStderr && consolePatched) is intentional — it's specifically how JSON-producing commands deliver their payload to stdout while diagnostic logs go to stderr.

Why the Aisle concern doesn't apply here:

  • Commands that produce JSON output (e.g. agents list --json, channels list --json) use runtime.log(JSON.stringify(...)) to write the payload. This must go to stdout for | jq pipelines to work.
  • The runtime.log → stdout path is the designed output channel; the Codex reviewer confirmed this is the correct behavior.
  • The [secrets] diagnostic strings Aisle references are emitted via console.log, which this PR now correctly redirects to stderr in --json mode.

Follow-up opportunity: Aisle's suggestion to split runtime.log vs runtime.json is a reasonable future improvement (clearer API contract), but orthogonal to the bug fix here. Could be tracked as a separate enhancement.

@steipete steipete force-pushed the claude/festive-lehmann branch from 933d0d8 to 99e31c2 Compare March 22, 2026 22:43
@steipete steipete merged commit 81b498b into openclaw:main Mar 22, 2026
3 checks passed
@steipete
Copy link
Copy Markdown
Contributor

Landed via temp rebase onto main.

  • Gate: pnpm check; pnpm build; pnpm check:docs; OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test
  • Land commit: 99e31c2
  • Merge commit: 81b498b

Thanks @cgdusek!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/logging/console.ts
}
if (loggingState.forceConsoleToStderr) {
// in RPC/JSON mode, keep stdout clean
if (loggingState.forceConsoleToStderr && !isJsonPayload(trimmed)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli CLI command changes size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: --json CLI flags output plugin logs to stdout, breaking JSON parsing

2 participants