Skip to content

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

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

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) [email protected]

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) <[email protected]>
@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..."

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

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) <[email protected]>
@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 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 👍 / 👎.

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