Skip to content

fix(cli): route diagnostic logs to stderr when stdout is piped#51641

Closed
shivama205 wants to merge 3 commits intoopenclaw:mainfrom
shivama205:fix/plugin-logs-stdout-leak
Closed

fix(cli): route diagnostic logs to stderr when stdout is piped#51641
shivama205 wants to merge 3 commits intoopenclaw:mainfrom
shivama205:fix/plugin-logs-stdout-leak

Conversation

@shivama205
Copy link
Copy Markdown

@shivama205 shivama205 commented Mar 21, 2026

Summary

  • Problem: Plugin init logs ([plugins] lines) are written to stdout instead of stderr, contaminating captured output in scripts that use $(openclaw message send ...) or pipe the CLI output.
  • Root cause: writeConsoleLine() in the subsystem logger routes info-level diagnostic messages through console.log (stdout), even when stdout is piped/captured.
  • Fix: In writeConsoleLine(), route info/debug/trace-level subsystem diagnostic logs to stderr when !process.stdout.isTTY. This is scoped to the subsystem logger sink only — command data output via runtime.log() / console.log() is unaffected.
  • Bonus: Version output in help.ts now uses process.stdout.write() directly since it's data, not a diagnostic log.

Closes #51496

Change type

  • Bug fix

Scope

  • Gateway / CLI

How it works

There are two separate output paths in the CLI:

  1. Command data: runtime.log()console.log()forward() wrapper → stdout (unchanged)
  2. Diagnostic logs: createSubsystemLogger()writeConsoleLine()rawConsole.logstdout (this is the bug)

The fix adds a TTY check in writeConsoleLine() (src/logging/subsystem.ts):

} else if (!process.stdout.isTTY) {
  // When stdout is piped/captured, route subsystem diagnostic logs to stderr
  (sink.error ?? console.error)(sanitized);
} else {
  (sink.log ?? console.log)(sanitized);
}

This only affects subsystem logger output. --version, --json, and all runtime.log() command output stays on stdout.

Test plan

  • New test: subsystem logger routes info-level logs to stderr when stdout is not a TTY
  • New test: subsystem logger routes info-level logs to stdout when stdout is a TTY
  • Updated test: version output now asserts process.stdout.write() instead of console.log
  • All existing subsystem.test.ts tests pass (13 total)
  • All existing console-capture.test.ts tests pass (11 total)
  • All existing run-main.test.ts tests pass (15 total)
  • All existing help.test.ts tests pass (4 total)
  • pnpm tsgo passes

Note

This PR was AI-assisted using Claude (Opus 4.6) via Claude Code. All code has been reviewed and tested locally by a human.

🤖 Generated with Claude Code

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

When stdout is not a TTY (e.g. captured via $(...) or piped), plugin
init logs ([plugins] lines) contaminate stdout, breaking scripts that
parse command output. Route all diagnostic logs to stderr in non-TTY
mode using the existing routeLogsToStderr() mechanism.

Also fixes pre-existing format issue in daemon-cli/lifecycle.test.ts.

Closes openclaw#51496

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 21, 2026

Greptile Summary

This PR fixes plugin init logs (e.g. [plugins] lines) contaminating stdout when the CLI is used in piped scripts like $(openclaw message send ...). The fix is minimal and correct: a single TTY check in runCli() calls the pre-existing routeLogsToStderr() when !process.stdout.isTTY, redirecting all diagnostic log output to stderr while leaving stdout clean for command output. Interactive TTY sessions are entirely unaffected.

Key observations:

  • The placement of the TTY check (after enableConsoleCapture() but before any plugin/program imports) ensures all subsequent diagnostic output is captured on the right stream.
  • loggingState.forceConsoleToStderr is read lazily by the forward wrapper at call time, so there are no ordering or race-condition concerns.
  • When process.stdout.isTTY is undefined (CI, non-interactive environments), !undefined === true triggers the redirect — correct behavior for those contexts.
  • The new tests cover the happy path and the negative case. Minor note: the first test calls routeLogsToStderr() before enableConsoleCapture(), which is the reverse of the production call order — functionally safe but worth aligning for fidelity.

Confidence Score: 5/5

  • Safe to merge — minimal, targeted fix with correct behavior across all stdout states (TTY, piped, undefined).
  • The change is a single conditional call to an already-tested and well-understood helper. No new state is introduced, the fix is idempotent, and the only style concern (test call ordering) does not affect correctness or production behavior.
  • No files require special attention.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/logging/console-capture.test.ts
Line: 136-147

Comment:
**Test call order inverted from production**

The test calls `routeLogsToStderr()` before `enableConsoleCapture()`, but the production code in `run-main.ts` does the opposite — `enableConsoleCapture()` first, then `routeLogsToStderr()`. This works because `loggingState.forceConsoleToStderr` is read lazily inside the `forward` wrapper at call time (not captured at setup time), so both orderings are functionally equivalent. However, aligning the test with the actual production call order would make it a better regression guard.

```suggestion
  it("routes info-level console.log to process.stderr when enabled", () => {
    setLoggerOverride({ level: "info", file: tempLogPath() });
    enableConsoleCapture();
    routeLogsToStderr();
    const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true);
    const stdoutWrite = vi.spyOn(process.stdout, "write").mockReturnValue(true);
    console.log("[plugins] test message");
    expect(stderrWrite).toHaveBeenCalled();
    const written = String(stderrWrite.mock.calls[0]?.[0] ?? "");
    expect(written).toContain("test message");
    expect(stdoutWrite).not.toHaveBeenCalled();
  });
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix(cli): route diag..."

Comment thread src/logging/console-capture.test.ts Outdated
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: 84975f00cc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/cli/run-main.ts Outdated
When stdout is not a TTY (piped/captured), subsystem diagnostic logs
(e.g. [plugins] init lines) contaminate stdout, breaking scripts that
parse command output. Fix by routing info/debug/trace-level subsystem
logger output to stderr in writeConsoleLine() when !process.stdout.isTTY.

This is scoped to the subsystem logger sink only — command data output
via runtime.log()/console.log() is unaffected, so --json, --version,
and all other CLI data output stays on stdout.

Also changes version output in help.ts to use process.stdout.write()
directly (it's data, not a diagnostic log).

Closes openclaw#51496

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@shivama205
Copy link
Copy Markdown
Author

Addressed both review comments in the latest push:

@greptile-apps (test call order): Fixed — but went further than just swapping the call order. The original routeLogsToStderr() approach was too broad, so the test was rewritten to match the new implementation.

@chatgpt-codex-connector (version stdout concern): Great catch — this was a real bug. routeLogsToStderr() would have broken openclaw --version | cat (and all runtime.log() data output).

Revised approach: Instead of globally routing all console.log to stderr, the fix is now scoped to writeConsoleLine() in src/logging/subsystem.ts — which only handles subsystem diagnostic logs (like [plugins]). Command data output via runtime.log() / console.log() is unaffected. Additionally, version output in help.ts now uses process.stdout.write() directly since it's data, not a diagnostic log.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 21, 2026

Excellent revision! The scoped approach is much cleaner:

subsystem.ts:248-251 — TTY check is perfectly placed. Only routes info/debug/trace subsystem logs to stderr when piped, leaving runtime.log() untouched.

help.ts:114-117 — Good catch on the version bug. process.stdout.write() is correct here since version is data output, not a diagnostic.

The new tests properly verify both TTY states. This is a solid, surgical fix that solves the original issue without side effects. Ready to ship.

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: 5664f0d2bf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/logging/subsystem.ts
Comment on lines +248 to +251
} else if (!process.stdout.isTTY) {
// When stdout is piped/captured, route subsystem diagnostic logs to stderr
// so stdout stays clean for command data output (runtime.log / JSON results).
(sink.error ?? console.error)(sanitized);
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 Scope non-TTY stderr routing to machine-output commands

Routing every non-warn/error subsystem line through console.error whenever stdout is non-TTY changes the stream for normal operational logs (for example gateway startup info logs) in common daemon/container runs where stdout.isTTY is false. That makes healthy info traffic appear on stderr, which many log pipelines and supervisors classify as error output, creating false alerts and breaking stdout-only log consumers; this routing should be gated by an explicit “stdout reserved for command data” mode instead of all non-TTY executions.

Useful? React with 👍 / 👎.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 26, 2026

Closing this as duplicate or superseded after Codex automated review.

Close as superseded. Current main uses the safer scoped stdout-reservation path from merged PR #52449/current startup policy, while #51641's non-TTY subsystem-wide stderr routing is intentionally not present because it was too broad for daemon/container/CI logs.

Best possible solution:

Close #51641 as superseded by merged #52449 and the current scoped JSON/stdout-reservation startup path. Keep stderr routing tied to explicit machine-output modes and plugin-load save/restore; handle any future non-JSON stdout contract gap with a targeted command-level issue or PR.

What I checked:

So I’m closing this here and keeping the remaining discussion on the canonical linked item.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against 76cf013df507; fix evidence: release 2026.3.22, commit c0b472897f88.

@clawsweeper clawsweeper Bot closed this Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli CLI command changes size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin init logs leak to stdout in CLI subcommands (message send, etc.)

1 participant