From 4ea7562f503492b5e272df4554af497306bcabc4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 22 May 2026 20:39:52 +0900 Subject: [PATCH 1/3] feat(shared): add Node-safe process stream reader and search output collector Introduces: - src/shared/process-stream-reader.ts: Buffer-concat stream reader compatible with both Bun and Node ChildProcess stdout (replaces Web Response API usage) - src/tools/shared/search-process-output.ts: structured subprocess output collector with timeout, kill, and rejection cleanup - bun-spawn-shim hardened: Node path forces windowsHide: true; spawn errors no longer escape as unhandledRejection Foundation for #3919 fix. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/bun-spawn-shim.test.ts | 44 +++++++++- src/shared/bun-spawn-shim.ts | 98 ++++++++++++++++++----- src/shared/process-stream-reader.ts | 63 +++++++++++++++ src/tools/shared/search-process-output.ts | 46 +++++++++++ 4 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 src/shared/process-stream-reader.ts create mode 100644 src/tools/shared/search-process-output.ts diff --git a/src/shared/bun-spawn-shim.test.ts b/src/shared/bun-spawn-shim.test.ts index 238cfb1b1..5258ef1e6 100644 --- a/src/shared/bun-spawn-shim.test.ts +++ b/src/shared/bun-spawn-shim.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" +import { Readable } from "node:stream" -import { spawn, spawnSync } from "./bun-spawn-shim" +import { createNodeSpawnOptions, createNodeSpawnSyncOptions, spawn, spawnSync } from "./bun-spawn-shim" +import { readProcessStream } from "./process-stream-reader" describe("bun-spawn-shim", () => { test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => { @@ -17,7 +19,7 @@ describe("bun-spawn-shim", () => { const [exitCode, stdout] = await Promise.all([ proc.exited, - new Response(proc.stdout).text(), + readProcessStream(proc.stdout), ]) expect(exitCode).toBe(0) @@ -48,7 +50,7 @@ describe("bun-spawn-shim", () => { }) const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() + const stdout = await readProcessStream(proc.stdout) expect(exitCode).toBe(0) expect(stdout).toBe("") @@ -60,7 +62,7 @@ describe("bun-spawn-shim", () => { expect(result.exitCode).toBe(0) expect(result.success).toBe(true) expect(result.stdout).toBeDefined() - expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok") + expect(result.stdout?.toString().trim()).toBe("sync-ok") }) test("#given spawnSync command #when it completes #then result.pid is a positive number", () => { @@ -88,4 +90,38 @@ describe("bun-spawn-shim", () => { expect(observedError).toBeDefined() }) + + test("#given Windows platform #when building Node spawn options #then windowsHide is enabled", () => { + const options = createNodeSpawnOptions({ stdout: "pipe", stderr: "pipe" }, "win32") + + expect(options.windowsHide).toBe(true) + expect(options.shell).toBe(false) + }) + + test("#given Windows platform #when building Node spawnSync options #then windowsHide is enabled", () => { + const options = createNodeSpawnSyncOptions({ stdout: "pipe", stderr: "pipe" }, "win32") + + expect(options.windowsHide).toBe(true) + expect(options.shell).toBe(false) + }) + + test("#given Node readable output #when reading in a non-Bun host shape #then Buffer-concat returns text", async () => { + const stream = Readable.from([Buffer.from("node-stream-ok\n")]) + + const output = await readProcessStream(stream) + + expect(output).toBe("node-stream-ok\n") + }) + + test("#given empty process stream #when reading process output #then returns an empty string", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + const output = await readProcessStream(stream) + + expect(output).toBe("") + }) }) diff --git a/src/shared/bun-spawn-shim.ts b/src/shared/bun-spawn-shim.ts index d07a48161..36260135e 100644 --- a/src/shared/bun-spawn-shim.ts +++ b/src/shared/bun-spawn-shim.ts @@ -1,4 +1,9 @@ -import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process" +import { + spawn as nodeSpawn, + spawnSync as nodeSpawnSync, + type SpawnOptions as NodeSpawnOptions, + type SpawnSyncOptions as NodeSpawnSyncOptions, +} from "node:child_process" import { Readable, Writable } from "node:stream" type AnyRecord = Record @@ -45,7 +50,10 @@ type BunSpawnRuntime = { } const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime } -const IS_BUN = typeof runtime.Bun !== "undefined" + +function getBunRuntime(): BunSpawnRuntime | undefined { + return typeof Bun === "undefined" ? undefined : runtime.Bun +} function emptyReadableStream(): ReadableStream { return new ReadableStream({ @@ -85,6 +93,48 @@ function resolveStdio(options: SpawnOptions): StdioTuple { return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"] } +export function createNodeSpawnOptions( + options: SpawnOptions, + platform: NodeJS.Platform = process.platform +): NodeSpawnOptions { + const nodeOptions: NodeSpawnOptions = { + stdio: resolveStdio(options), + shell: false, + } + + if (options.cwd !== undefined) nodeOptions.cwd = options.cwd + if (options.env !== undefined) nodeOptions.env = options.env + if (options.detached !== undefined) nodeOptions.detached = options.detached + if (options.signal !== undefined) nodeOptions.signal = options.signal + + if (platform === "win32") { + // #3919: Windows Desktop utility processes must hide child consoles when spawning tools. + nodeOptions.windowsHide = true + } + + return nodeOptions +} + +export function createNodeSpawnSyncOptions( + options: SpawnOptions, + platform: NodeJS.Platform = process.platform +): NodeSpawnSyncOptions { + const nodeOptions: NodeSpawnSyncOptions = { + stdio: resolveStdio(options), + shell: false, + } + + if (options.cwd !== undefined) nodeOptions.cwd = options.cwd + if (options.env !== undefined) nodeOptions.env = options.env + + if (platform === "win32") { + // #3919: Match async spawn so Windows sync probes do not surface a console window. + nodeOptions.windowsHide = true + } + + return nodeOptions +} + function wrapNodeProcess(proc: ReturnType): SpawnedProcess { let exitCode: number | null = null const exited = new Promise((resolve, reject) => { @@ -127,20 +177,27 @@ function wrapNodeProcess(proc: ReturnType): SpawnedProcess { } } +function toSpawnSyncBuffer(output: Buffer | string | null): Buffer | undefined { + if (output === null) { + return undefined + } + + return Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8") +} + export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess { - if (IS_BUN) return runtime.Bun!.spawn(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions) - const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts) + const bun = getBunRuntime() + if (bun) return bun.spawn(cmd, options) + const [bin, ...args] = cmd - const proc = nodeSpawn(bin, args, { - cwd: options.cwd, - env: options.env, - stdio: resolveStdio(options), - detached: options.detached, - signal: options.signal, - }) + if (bin === undefined) { + throw new Error("Cannot spawn an empty command") + } + + const proc = nodeSpawn(bin, args, createNodeSpawnOptions(options)) return wrapNodeProcess(proc) } @@ -148,20 +205,21 @@ export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess { export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult { - if (IS_BUN) return runtime.Bun!.spawnSync(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions) - const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts) + const bun = getBunRuntime() + if (bun) return bun.spawnSync(cmd, options) + const [bin, ...args] = cmd - const result = nodeSpawnSync(bin, args, { - cwd: options.cwd, - env: options.env, - stdio: resolveStdio(options), - }) + if (bin === undefined) { + throw new Error("Cannot spawnSync an empty command") + } + + const result = nodeSpawnSync(bin, args, createNodeSpawnSyncOptions(options)) return { exitCode: result.status ?? 1, - stdout: result.stdout ?? undefined, - stderr: result.stderr ?? undefined, + stdout: toSpawnSyncBuffer(result.stdout), + stderr: toSpawnSyncBuffer(result.stderr), success: (result.status ?? 1) === 0, pid: result.pid ?? -1, } diff --git a/src/shared/process-stream-reader.ts b/src/shared/process-stream-reader.ts new file mode 100644 index 000000000..bec08fab7 --- /dev/null +++ b/src/shared/process-stream-reader.ts @@ -0,0 +1,63 @@ +import { Readable } from "node:stream" + +export type ProcessReadableStream = ReadableStream | Readable | null | undefined + +function bufferFromChunk(chunk: unknown): Buffer { + if (Buffer.isBuffer(chunk)) { + return chunk + } + + if (chunk instanceof Uint8Array) { + return Buffer.from(chunk) + } + + if (typeof chunk === "string") { + return Buffer.from(chunk, "utf8") + } + + throw new TypeError(`Unsupported process stream chunk type: ${typeof chunk}`) +} + +async function readWebStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const chunks: Buffer[] = [] + + try { + while (true) { + const result = await reader.read() + if (result.done) { + return chunks + } + chunks.push(Buffer.from(result.value)) + } + } finally { + reader.releaseLock() + } +} + +async function readNodeStream(stream: Readable): Promise { + const chunks: Buffer[] = [] + + for await (const chunk of stream) { + chunks.push(bufferFromChunk(chunk)) + } + + return chunks +} + +function isWebReadableStream(stream: ProcessReadableStream): stream is ReadableStream { + return typeof ReadableStream !== "undefined" && stream instanceof ReadableStream +} + +export async function readProcessStream(stream: ProcessReadableStream): Promise { + if (!stream) { + return "" + } + + // #3919: Buffer-concat avoids Response(stream).text() crashes in Windows utility processes. + const chunks = isWebReadableStream(stream) + ? await readWebStream(stream) + : await readNodeStream(stream) + + return Buffer.concat(chunks).toString("utf8") +} diff --git a/src/tools/shared/search-process-output.ts b/src/tools/shared/search-process-output.ts new file mode 100644 index 000000000..f6d5945d0 --- /dev/null +++ b/src/tools/shared/search-process-output.ts @@ -0,0 +1,46 @@ +import type { SpawnedProcess } from "../../shared/bun-spawn-shim" +import { readProcessStream } from "../../shared/process-stream-reader" + +export interface SearchProcessOutput { + readonly stdout: string + readonly stderr: string + readonly exitCode: number +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function createProcessTimeout( + proc: SpawnedProcess, + timeoutMs: number, + timeoutMessage: string +): Promise { + return new Promise((_, reject) => { + const id = setTimeout(() => { + proc.kill() + reject(new Error(timeoutMessage)) + }, timeoutMs) + + // #3919: Handle rejected exits here so timeout cleanup cannot leak unhandled rejections. + void proc.exited.then( + () => clearTimeout(id), + () => clearTimeout(id) + ) + }) +} + +export async function collectSearchProcessOutput( + proc: SpawnedProcess, + timeoutMs: number, + timeoutMessage: string +): Promise { + const stderrPromise = readProcessStream(proc.stderr).catch(getErrorMessage) + const stdout = await Promise.race([ + readProcessStream(proc.stdout), + createProcessTimeout(proc, timeoutMs, timeoutMessage), + ]) + const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]) + + return { stdout, stderr, exitCode } +} From d17b2127f22acca5911dd8b3dd59b0e0705cdeb9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 22 May 2026 20:40:06 +0900 Subject: [PATCH 2/3] fix(tools/grep, tools/glob): use Node-safe subprocess streaming (#3919) OpenCode Desktop v1.14.41+ runs OMO inside a Node.js utility process. The previous `new Response(proc.stdout).text()` call is Bun-/Web-API-specific and crashed the Desktop sidecar on Windows when grep/glob were invoked. Switch glob/grep cli to the new process-stream-reader + search-process-output helpers. Behavior on Bun and CLI/Linux/macOS is unchanged. ripgrep auto-download, PowerShell fallback, and rgSemaphore are preserved. Fixes #3919 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/tools/glob/cli.test.ts | 63 ++++++++++++++++++++++++++++- src/tools/glob/cli.ts | 47 +++++++++++----------- src/tools/grep/cli.test.ts | 53 ++++++++++++++++++++++++ src/tools/grep/cli.ts | 82 +++++++++++++++++++++----------------- 4 files changed, 183 insertions(+), 62 deletions(-) create mode 100644 src/tools/grep/cli.test.ts diff --git a/src/tools/glob/cli.test.ts b/src/tools/glob/cli.test.ts index dcc393eb4..a1f8f3dad 100644 --- a/src/tools/glob/cli.test.ts +++ b/src/tools/glob/cli.test.ts @@ -1,5 +1,36 @@ -import { describe, it, expect } from "bun:test" -import { buildRgArgs, buildFindArgs, buildPowerShellCommand } from "./cli" +import { describe, it, expect, mock } from "bun:test" +import { Writable } from "node:stream" +import type { SpawnOptions, SpawnedProcess } from "../../shared/bun-spawn-shim" +import { buildRgArgs, buildFindArgs, buildPowerShellCommand, runRgFiles } from "./cli" + +function createTextStream(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + if (text.length > 0) { + controller.enqueue(new TextEncoder().encode(text)) + } + controller.close() + }, + }) +} + +function createSpawnedProcess(exitCode: number, stdout = "", stderr = ""): SpawnedProcess { + return { + exitCode, + exited: Promise.resolve(exitCode), + stdout: createTextStream(stdout), + stderr: createTextStream(stderr), + stdin: new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }), + pid: 3919, + kill() {}, + ref() {}, + unref() {}, + } +} describe("buildRgArgs", () => { // given default options (no hidden/follow specified) @@ -166,4 +197,32 @@ describe("buildPowerShellCommand", () => { const command = args.join(" ") expect(command).toContain("test''s.ts") }) + + it("uses LiteralPath so fallback paths are not wildcard-expanded (#3919)", () => { + const args = buildPowerShellCommand({ pattern: "*.ts", paths: ["C:\\repo[1]"] }) + const command = args.join(" ") + expect(args[0]).toBe("powershell.exe") + expect(command).toContain("Get-ChildItem -LiteralPath 'C:\\repo[1]'") + }) +}) + +describe("runRgFiles", () => { + it("#given empty stdout #when rg exits successfully #then returns an empty result", async () => { + const spawnMock = mock((_command: string[], _options?: SpawnOptions): SpawnedProcess => + createSpawnedProcess(0) + ) + + const result = await runRgFiles( + { pattern: "*.ts", paths: ["."], timeout: 1000 }, + { path: "rg", backend: "rg" }, + spawnMock + ) + + expect(result).toEqual({ + files: [], + totalFiles: 0, + truncated: false, + }) + expect(spawnMock).toHaveBeenCalled() + }) }) diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 9ba34c32a..ab97493b9 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path" -import { spawn } from "../../shared/bun-spawn-shim" +import { spawn, type SpawnOptions, type SpawnedProcess } from "../../shared/bun-spawn-shim" import { resolveGrepCli, type GrepBackend, @@ -13,12 +13,15 @@ import { import type { GlobOptions, GlobResult, FileMatch } from "./types" import { stat } from "node:fs/promises" import { rgSemaphore } from "../shared/semaphore" +import { collectSearchProcessOutput } from "../shared/search-process-output" export interface ResolvedCli { path: string backend: GrepBackend } +export type SearchProcessSpawner = (command: string[], options?: SpawnOptions) => SpawnedProcess + function buildRgArgs(options: GlobOptions): string[] { const args: string[] = [ ...RG_FILES_FLAGS, @@ -65,7 +68,8 @@ function buildPowerShellCommand(options: GlobOptions): string[] { const escapedPath = searchPath.replace(/'/g, "''") const escapedPattern = options.pattern.replace(/'/g, "''") - let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'` + // #3919: Keep PowerShell fallback direct-spawned and single-quote escaped, not shell-interpolated. + let psCommand = `Get-ChildItem -LiteralPath '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'` if (options.hidden !== false) { psCommand += " -Force" @@ -78,7 +82,7 @@ function buildPowerShellCommand(options: GlobOptions): string[] { psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName" - return ["powershell", "-NoProfile", "-Command", psCommand] + return ["powershell.exe", "-NoProfile", "-Command", psCommand] } async function getFileMtime(filePath: string): Promise { @@ -94,11 +98,12 @@ export { buildRgArgs, buildFindArgs, buildPowerShellCommand } export async function runRgFiles( options: GlobOptions, - resolvedCli?: ResolvedCli + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn ): Promise { await rgSemaphore.acquire() try { - return await runRgFilesInternal(options, resolvedCli) + return await runRgFilesInternal(options, resolvedCli, processSpawner) } finally { rgSemaphore.release() } @@ -106,7 +111,8 @@ export async function runRgFiles( async function runRgFilesInternal( options: GlobOptions, - resolvedCli?: ResolvedCli + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn ): Promise { const cli = resolvedCli ?? resolveGrepCli() const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) @@ -133,24 +139,19 @@ async function runRgFilesInternal( command = [cli.path, ...args] } - const proc = spawn(command, { - stdout: "pipe", - stderr: "pipe", - cwd, - }) - - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - proc.kill() - reject(new Error(`Glob search timeout after ${timeout}ms`)) - }, timeout) - proc.exited.then(() => clearTimeout(id)) - }) - try { - const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) - const stderr = await new Response(proc.stderr).text() - const exitCode = await proc.exited + const proc = processSpawner(command, { + stdout: "pipe", + stderr: "pipe", + cwd, + }) + + // #3919: Read stdout/stderr with Buffer concat instead of Response(stream).text(). + const { stdout, stderr, exitCode } = await collectSearchProcessOutput( + proc, + timeout, + `Glob search timeout after ${timeout}ms` + ) if (exitCode > 1 && stderr.trim()) { return { diff --git a/src/tools/grep/cli.test.ts b/src/tools/grep/cli.test.ts new file mode 100644 index 000000000..a629f7a90 --- /dev/null +++ b/src/tools/grep/cli.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, mock } from "bun:test" +import { Writable } from "node:stream" +import type { SpawnOptions, SpawnedProcess } from "../../shared/bun-spawn-shim" +import { runRg } from "./cli" + +function createTextStream(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + if (text.length > 0) { + controller.enqueue(new TextEncoder().encode(text)) + } + controller.close() + }, + }) +} + +function createSpawnedProcess(exited: Promise, stdout = "", stderr = ""): SpawnedProcess { + return { + exitCode: null, + exited, + stdout: createTextStream(stdout), + stderr: createTextStream(stderr), + stdin: new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }), + pid: 3919, + kill() {}, + ref() {}, + unref() {}, + } +} + +describe("runRg", () => { + it("#given mocked spawn rejection #when grep runs #then returns a structured error result", async () => { + const spawnMock = mock((_command: string[], _options?: SpawnOptions): SpawnedProcess => + createSpawnedProcess(Promise.reject(new Error("spawn rejected"))) + ) + + const result = await runRg( + { pattern: "needle", paths: ["."], timeout: 1000 }, + { path: "rg", backend: "rg" }, + spawnMock + ) + + expect(result.matches).toEqual([]) + expect(result.totalMatches).toBe(0) + expect(result.filesSearched).toBe(0) + expect(result.truncated).toBe(false) + expect(result.error).toContain("spawn rejected") + }) +}) diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index 4b9684c66..59a12f61f 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "../../shared/bun-spawn-shim" +import { spawn, type SpawnOptions, type SpawnedProcess } from "../../shared/bun-spawn-shim" import { resolveGrepCli, type ResolvedCli, @@ -17,6 +17,9 @@ import { } from "./constants" import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types" import { rgSemaphore } from "../shared/semaphore" +import { collectSearchProcessOutput } from "../shared/search-process-output" + +export type SearchProcessSpawner = (command: string[], options?: SpawnOptions) => SpawnedProcess function buildRgArgs(options: GrepOptions): string[] { const args: string[] = [ @@ -154,16 +157,24 @@ function parseCountOutput(output: string): CountResult[] { return results } -export async function runRg(options: GrepOptions, resolvedCli?: ResolvedCli): Promise { +export async function runRg( + options: GrepOptions, + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn +): Promise { await rgSemaphore.acquire() try { - return await runRgInternal(options, resolvedCli) + return await runRgInternal(options, resolvedCli, processSpawner) } finally { rgSemaphore.release() } } -async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): Promise { +async function runRgInternal( + options: GrepOptions, + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn +): Promise { const cli = resolvedCli ?? resolveGrepCli() const args = buildArgs(options, cli.backend) const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) @@ -176,23 +187,18 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P const paths = options.paths?.length ? options.paths : ["."] args.push(...paths) - const proc = spawn([cli.path, ...args], { - stdout: "pipe", - stderr: "pipe", - }) - - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - proc.kill() - reject(new Error(`Search timeout after ${timeout}ms`)) - }, timeout) - proc.exited.then(() => clearTimeout(id)) - }) - try { - const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) - const stderr = await new Response(proc.stderr).text() - const exitCode = await proc.exited + const proc = processSpawner([cli.path, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + // #3919: Read stdout/stderr with Buffer concat instead of Response(stream).text(). + const { stdout, stderr, exitCode } = await collectSearchProcessOutput( + proc, + timeout, + `Search timeout after ${timeout}ms` + ) const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout @@ -232,11 +238,12 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P export async function runRgCount( options: Omit, - resolvedCli?: ResolvedCli + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn ): Promise { await rgSemaphore.acquire() try { - return await runRgCountInternal(options, resolvedCli) + return await runRgCountInternal(options, resolvedCli, processSpawner) } finally { rgSemaphore.release() } @@ -244,7 +251,8 @@ export async function runRgCount( async function runRgCountInternal( options: Omit, - resolvedCli?: ResolvedCli + resolvedCli?: ResolvedCli, + processSpawner: SearchProcessSpawner = spawn ): Promise { const cli = resolvedCli ?? resolveGrepCli() const args = buildArgs({ ...options, context: 0 }, cli.backend) @@ -259,21 +267,21 @@ async function runRgCountInternal( args.push(...paths) const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) - const proc = spawn([cli.path, ...args], { - stdout: "pipe", - stderr: "pipe", - }) - - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - proc.kill() - reject(new Error(`Search timeout after ${timeout}ms`)) - }, timeout) - proc.exited.then(() => clearTimeout(id)) - }) - try { - const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) + const proc = processSpawner([cli.path, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + // #3919: Count mode uses the same Node-safe stream reader as normal grep. + const { stdout, stderr, exitCode } = await collectSearchProcessOutput( + proc, + timeout, + `Search timeout after ${timeout}ms` + ) + if (exitCode > 1 && stderr.trim()) { + throw new Error(stderr.trim()) + } return parseCountOutput(stdout) } catch (e) { throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`) From aded57ff1fc68f90d8c81e557b64bb0827bfa6a9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 22 May 2026 20:40:24 +0900 Subject: [PATCH 3/3] fix(shared): harden ripgrep-cli, zip-extractor, binary-downloader subprocess paths Same Web-Response-on-Node hazard existed in ripgrep auto-download flow, zip extraction helpers, and binary downloader streams. Switch to the new Node-safe reader and ensure no spawn path escapes as unhandledRejection. Related to #3919. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/binary-downloader.ts | 9 ++++++--- src/shared/ripgrep-cli.ts | 15 +++++++++++---- .../powershell-zip-entry-listing.ts | 6 ++++-- .../zip-entry-listing/python-zip-entry-listing.ts | 6 ++++-- .../zip-entry-listing/read-zip-symlink-target.ts | 6 ++++-- .../zip-entry-listing/tar-zip-entry-listing.ts | 6 ++++-- .../zipinfo-zip-entry-listing.ts | 6 ++++-- src/shared/zip-extractor.ts | 8 +++++--- 8 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts index a44206c2e..a36c4a89a 100644 --- a/src/shared/binary-downloader.ts +++ b/src/shared/binary-downloader.ts @@ -4,6 +4,7 @@ import { spawn } from "./bun-spawn-shim"; import { bunWrite } from "./bun-file-shim"; import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator"; import { extractZip } from "./zip-extractor"; +import { readProcessStream } from "./process-stream-reader"; function isTarTraversalErrorOutput(output: string): boolean { return /path contains '\.\.'|member name contains '\.\.'|removing leading [`'\"]?\.\.\//i.test(output) @@ -47,7 +48,8 @@ export async function extractTarGz( const exitCode = await proc.exited; if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); + // #3919: Avoid Response(stream).text() in Windows Desktop utility processes. + const stderr = await readProcessStream(proc.stderr); if (isTarTraversalErrorOutput(stderr)) { throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`) @@ -107,8 +109,9 @@ async function listTarEntries(archivePath: string, cwd?: string): Promise