From 32ec94a0a55a352394cab045246be99564a90c4e Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:14:53 +0530 Subject: [PATCH 1/3] fix(coding-agent): keep Bun console output out of the RPC/JSON stdout stream On Bun, console.log/info/debug/dir/dirxml/table/trace/count/group and console.write print to fd 1 without calling a replaced process.stdout.write (oven-sh/bun#47982), so an extension's console.log corrupted the JSON stream in RPC, JSON, ACP and MCP modes. While stdout is taken over, route them through a node:console Console over process.stdout, which goes to stderr as on Node; restore them after. --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/output-guard.ts | 70 +++++++++ .../test/fixtures/rpc-console-session.ts | 40 ++++++ .../regressions/rpc-console-stdout.test.ts | 134 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/coding-agent/test/fixtures/rpc-console-session.ts create mode 100644 packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0cbf81936..96fcc5432e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - The background service no longer aborts a running turn when a phone opens another session or starts a new one. It keeps up to 3 sessions open, each with its own runtime: a session in a turn keeps running in the background, and opening it again shows it as it is. With all 3 running, the phone is told to stop one or wait. `remote_hello.host.parallelSessions` says the service does this, and `list_sessions` marks sessions as `open` and `running` - Remote control spends a pairing secret only when the phone completes the handshake (`remote_ready`), not when its first handshake message arrives. A phone whose connection timed out mid-handshake (the apps give each address 3 seconds; iOS's first connect can wait on the Local Network prompt) had used up its QR code, so its retry was refused and pairing needed a new code. The secret still pairs one phone: of two phones racing with it, the first to finish pairs and the other is refused +- On the Bun runtime (Pi-Bolt), a `console.log` from an extension or a dependency in RPC, JSON, ACP or MCP mode (and `pi-bolt service`) no longer goes into the JSON stream on stdout. Bun's console methods (`log`, `info`, `debug`, `dir`, `dirxml`, `table`, `trace`, `count`, `group`, `write`) print to fd 1 without calling the replaced `process.stdout.write` ([oven-sh/bun#47982](https://github.com/oven-sh/bun/issues/47982)); while stdout is taken over they now print to stderr, formatted as on Node ## [1.5.0] - 2026-11-02 diff --git a/packages/coding-agent/src/core/output-guard.ts b/packages/coding-agent/src/core/output-guard.ts index 20b6e383df..16643666b2 100644 --- a/packages/coding-agent/src/core/output-guard.ts +++ b/packages/coding-agent/src/core/output-guard.ts @@ -1,9 +1,35 @@ +import { Console } from "node:console"; +import { format } from "node:util"; + interface StdoutTakeoverState { rawStdoutWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; originalStdoutWrite: typeof process.stdout.write; + /** Console methods replaced while stdout is taken over, to put back at the end. */ + originalConsoleMethods: Map; } +/** + * The console methods that print to stdout on Bun without calling `process.stdout.write` + * (https://github.com/oven-sh/bun/issues/47982). Node's console calls the replaced `process.stdout.write`, so its + * output goes to stderr; on Bun it went straight to fd 1, into the JSON stream. `trace` and Bun's `write` print to fd 1 + * too and are replaced separately. Bun's time* methods already print to stderr; count/countReset and the group methods + * are replaced together so their state stays in one console. + */ +const BUN_STDOUT_CONSOLE_METHODS = [ + "log", + "info", + "debug", + "dir", + "dirxml", + "table", + "count", + "countReset", + "group", + "groupCollapsed", + "groupEnd", +] as const; + let stdoutTakeoverState: StdoutTakeoverState | undefined; const RAW_STDOUT_RETRY_DELAY_MS = 10; @@ -80,15 +106,59 @@ export function takeOverStdout(): void { rawStdoutWrite, rawStderrWrite, originalStdoutWrite, + originalConsoleMethods: process.versions.bun ? routeBunConsoleThroughStdoutWrite() : new Map(), }; } +/** + * Point Bun's stdout console methods at a `Console` over `process.stdout`, which calls the replaced write as Node's + * console does, so the output and its formatting match Node. Bun's `console.write` (raw, no newline) goes the same way. + */ +function routeBunConsoleThroughStdoutWrite(): Map { + const target = console as unknown as Record; + const originals = new Map(); + const nodeConsole = new Console({ stdout: process.stdout, stderr: process.stderr }); + for (const name of BUN_STDOUT_CONSOLE_METHODS) { + originals.set(name, target[name]); + target[name] = nodeConsole[name].bind(nodeConsole); + } + // Bun's Console prints a trace's stack without its "Trace: " line; Node prints it to stderr with the line. + const trace = (...data: unknown[]): void => { + const error = new Error(format(...data)); + error.name = "Trace"; + Error.captureStackTrace(error, trace); + nodeConsole.error(error.stack); + }; + originals.set("trace", target.trace); + target.trace = trace; + if (typeof target.write === "function") { + originals.set("write", target.write); + target.write = (...data: Array): number => { + let bytes = 0; + for (const chunk of data) { + const buffer = + typeof chunk === "string" + ? Buffer.from(chunk) + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + process.stdout.write(buffer.toString()); + bytes += buffer.byteLength; + } + return bytes; + }; + } + return originals; +} + export function restoreStdout(): void { if (!stdoutTakeoverState) { return; } process.stdout.write = stdoutTakeoverState.originalStdoutWrite; + const target = console as unknown as Record; + for (const [name, method] of stdoutTakeoverState.originalConsoleMethods) { + target[name] = method; + } stdoutTakeoverState = undefined; } diff --git a/packages/coding-agent/test/fixtures/rpc-console-session.ts b/packages/coding-agent/test/fixtures/rpc-console-session.ts new file mode 100644 index 0000000000..ca82519497 --- /dev/null +++ b/packages/coding-agent/test/fixtures/rpc-console-session.ts @@ -0,0 +1,40 @@ +// Run by test/suite/regressions/rpc-console-stdout.test.ts under Bun: an RPC session (faux provider) whose extension +// prints with every console method that writes to stdout. Commands come on stdin; the session ends when stdin closes. +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import type { AgentSessionRuntime } from "../../src/core/agent-session-runtime.ts"; +import type { ExtensionAPI } from "../../src/core/extensions/types.ts"; +import { takeOverStdout } from "../../src/core/output-guard.ts"; +import { runRpcMode } from "../../src/modes/rpc/rpc-mode.ts"; +import { createHarness } from "../suite/harness.ts"; + +// As main.ts does: stdout is taken over before extensions load. +takeOverStdout(); +const harness = await createHarness({ + extensionFactories: [ + (pi: ExtensionAPI) => { + console.log("console-marker extension loaded", { a: 1 }); + pi.on("agent_start", () => { + console.log("console-marker log %s", "x"); + console.info("console-marker info"); + console.debug("console-marker debug"); + console.dir({ marker: "console-marker dir" }); + console.dirxml("console-marker dirxml"); + console.table([{ marker: "console-marker table" }]); + console.trace("console-marker trace"); + console.count("console-marker count"); + console.group("console-marker group"); + console.log("console-marker in group"); + console.groupEnd(); + (console as { write?: (text: string) => void }).write?.("console-marker write\n"); + }); + }, + ], +}); +harness.setResponses([fauxAssistantMessage("done")]); +process.on("exit", () => harness.cleanup()); + +await runRpcMode({ + session: harness.session, + dispose: async () => {}, + setRebindSession: () => {}, +} as unknown as AgentSessionRuntime); diff --git a/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts b/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts new file mode 100644 index 0000000000..b6e289f3a6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts @@ -0,0 +1,134 @@ +import { spawn, spawnSync } from "node:child_process"; +import { Console } from "node:console"; +import { fileURLToPath } from "node:url"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts"; +import { restoreStdout } from "../../../src/core/output-guard.ts"; +import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +// Bun's console methods print to fd 1 without calling a replaced process.stdout.write +// (https://github.com/oven-sh/bun/issues/47982), so an extension's console.log went into the RPC stream. + +const rpcInput = vi.hoisted(() => ({ lineHandler: undefined as ((line: string) => void) | undefined })); + +vi.mock("../../../src/modes/rpc/jsonl.js", async (importOriginal) => ({ + ...(await importOriginal()), + attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => { + rpcInput.lineHandler = onLine; + return () => { + rpcInput.lineHandler = undefined; + }; + }), +})); + +const fixture = fileURLToPath(new URL("../../fixtures/rpc-console-session.ts", import.meta.url)); +const hasBun = spawnSync("bun", ["--version"]).status === 0; + +function expectJsonLines(stdout: string): Array> { + const lines = stdout.split("\n").filter((line) => line.length > 0); + expect(lines.length).toBeGreaterThan(0); + return lines.map((line) => JSON.parse(line) as Record); +} + +describe("console output in RPC mode", () => { + const harnesses: Harness[] = []; + afterEach(() => { + for (const harness of harnesses.splice(0)) harness.cleanup(); + }); + + test("an extension's console.log goes to stderr and stdout stays JSON lines (Node)", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + const originalLog = console.log; + const originalInfo = console.info; + // Vitest replaces the global console with its own; use one over process.stdout as Node's global console is. + const nodeConsole = new Console({ stdout: process.stdout, stderr: process.stderr }); + console.log = nodeConsole.log.bind(nodeConsole); + console.info = nodeConsole.info.bind(nodeConsole); + process.stdout.write = ((chunk: string, ...rest: unknown[]) => { + stdout.push(String(chunk)); + (rest.find((arg) => typeof arg === "function") as (() => void) | undefined)?.(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string, ...rest: unknown[]) => { + stderr.push(String(chunk)); + (rest.find((arg) => typeof arg === "function") as (() => void) | undefined)?.(); + return true; + }) as typeof process.stderr.write; + + try { + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("agent_start", () => { + console.log("console-marker log", { a: 1 }); + console.info("console-marker info"); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("done")]); + void runRpcMode({ + session: harness.session, + dispose: async () => {}, + setRebindSession: () => {}, + } as unknown as AgentSessionRuntime); + await vi.waitFor(() => expect(rpcInput.lineHandler).toBeDefined()); + rpcInput.lineHandler?.(JSON.stringify({ id: "p", type: "prompt", message: "hi" })); + await vi.waitFor(() => expect(stdout.join("")).toContain('"type":"agent_end"')); + } finally { + restoreStdout(); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.log = originalLog; + console.info = originalInfo; + } + + expect(stdout.join("")).not.toContain("console-marker"); + expectJsonLines(stdout.join("")); + expect(stderr.join("")).toContain("console-marker log { a: 1 }\n"); + expect(stderr.join("")).toContain("console-marker info\n"); + }); + + test.skipIf(!hasBun)("every stdout console method of an extension goes to stderr under Bun", async () => { + const child = spawn("bun", [fixture], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + if (stdout.includes('"type":"agent_end"')) child.stdin.end(); + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const exitCode = new Promise((resolve) => child.on("exit", resolve)); + child.stdin.write(`${JSON.stringify({ id: "p", type: "prompt", message: "hi" })}\n`); + expect(await exitCode).toBe(0); + + const events = expectJsonLines(stdout); + expect(events.some((event) => event.type === "agent_end")).toBe(true); + expect(stdout).not.toContain("console-marker"); + for (const marker of [ + "console-marker extension loaded { a: 1 }\n", + "console-marker log x\n", + "console-marker info\n", + "console-marker debug\n", + "console-marker dir", + "console-marker dirxml\n", + "console-marker table", + "Trace: console-marker trace\n", + "console-marker count: 1\n", + "console-marker group\n console-marker in group\n", + "console-marker write\n", + ]) { + expect(stderr).toContain(marker); + } + }); +}); From befba935ae9c02f6b9b9f34381e9408ae071fbe3 Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:19:09 +0530 Subject: [PATCH 2/3] fix(coding-agent): use a top-level type import in the RPC console test --- .../test/suite/regressions/rpc-console-stdout.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts b/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts index b6e289f3a6..6908c9aa27 100644 --- a/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts +++ b/packages/coding-agent/test/suite/regressions/rpc-console-stdout.test.ts @@ -5,6 +5,7 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts"; import { restoreStdout } from "../../../src/core/output-guard.ts"; +import type * as Jsonl from "../../../src/modes/rpc/jsonl.ts"; import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts"; import { createHarness, type Harness } from "../harness.ts"; @@ -14,7 +15,7 @@ import { createHarness, type Harness } from "../harness.ts"; const rpcInput = vi.hoisted(() => ({ lineHandler: undefined as ((line: string) => void) | undefined })); vi.mock("../../../src/modes/rpc/jsonl.js", async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal()), attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => { rpcInput.lineHandler = onLine; return () => { From 27d6744e1e3854068b2956c498f8306c0db13286 Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:26:14 +0530 Subject: [PATCH 3/3] fix: read the Windows selftest extension's console.log from stderr With stdout taken over in -p mode, an extension's console.log now goes to stderr on Bun as it does on Node. --- packages/coding-agent/CHANGELOG.md | 2 +- scripts/windows-selftest.ps1 | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 96fcc5432e..d478c9e55e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,7 +17,7 @@ - The background service no longer aborts a running turn when a phone opens another session or starts a new one. It keeps up to 3 sessions open, each with its own runtime: a session in a turn keeps running in the background, and opening it again shows it as it is. With all 3 running, the phone is told to stop one or wait. `remote_hello.host.parallelSessions` says the service does this, and `list_sessions` marks sessions as `open` and `running` - Remote control spends a pairing secret only when the phone completes the handshake (`remote_ready`), not when its first handshake message arrives. A phone whose connection timed out mid-handshake (the apps give each address 3 seconds; iOS's first connect can wait on the Local Network prompt) had used up its QR code, so its retry was refused and pairing needed a new code. The secret still pairs one phone: of two phones racing with it, the first to finish pairs and the other is refused -- On the Bun runtime (Pi-Bolt), a `console.log` from an extension or a dependency in RPC, JSON, ACP or MCP mode (and `pi-bolt service`) no longer goes into the JSON stream on stdout. Bun's console methods (`log`, `info`, `debug`, `dir`, `dirxml`, `table`, `trace`, `count`, `group`, `write`) print to fd 1 without calling the replaced `process.stdout.write` ([oven-sh/bun#47982](https://github.com/oven-sh/bun/issues/47982)); while stdout is taken over they now print to stderr, formatted as on Node +- On the Bun runtime (Pi-Bolt), a `console.log` from an extension or a dependency in RPC, JSON, ACP or MCP mode (and `pi-bolt service`) no longer goes into the JSON stream on stdout, and in print mode (`-p`) no longer mixes with the answer. Bun's console methods (`log`, `info`, `debug`, `dir`, `dirxml`, `table`, `trace`, `count`, `group`, `write`) print to fd 1 without calling the replaced `process.stdout.write` ([oven-sh/bun#47982](https://github.com/oven-sh/bun/issues/47982)); while stdout is taken over they now print to stderr, formatted as on Node ## [1.5.0] - 2026-11-02 diff --git a/scripts/windows-selftest.ps1 b/scripts/windows-selftest.ps1 index 7fd5963535..3c4e47a66d 100644 --- a/scripts/windows-selftest.ps1 +++ b/scripts/windows-selftest.ps1 @@ -163,8 +163,9 @@ export default function () { }); } '@) + # Its console.log goes to stderr: in -p mode stdout is the answer's (as on Node). $r = Invoke-Pi @('-ne', '-e', $ext, '--no-session', '-p', 'hi') - Result "an extension (Node's modules, a program, workers, collections)" ($r.Out -match 'selftest-extension: ok') "exit $(Hex $r.Code) $($r.Out.Trim()) $($r.Err.Trim())" + Result "an extension (Node's modules, a program, workers, collections)" ($r.Err -match 'selftest-extension: ok') "exit $(Hex $r.Code) $($r.Out.Trim()) $($r.Err.Trim())" # The interactive interface starts in this console (its first frame), and ends on Ctrl+C... cannot be driven from here # without a terminal of its own: `--help` stands for it.