From 042190d9c1a4ca956e178696cc5b48e78e401fab Mon Sep 17 00:00:00 2001 From: ysmla Date: Mon, 14 Sep 2026 23:42:29 +0800 Subject: [PATCH] fix: force UTF-8 output encoding for PowerShell on Windows Windows PowerShell 5.1 encodes its stdout with the legacy OEM code page (e.g. CP936 on zh-CN systems) when output is redirected, while opencode decodes process output as UTF-8, producing mojibake for any non-ASCII shell output. Prepend a [Console]::OutputEncoding/$OutputEncoding UTF-8 preamble to PowerShell commands spawned by the shell tool and bang commands on Windows. Permission scanning and command titles still use the original command text since the preamble is injected at spawn time. Corresponds to upstream discussion in anomalyco/opencode#23636. --- packages/core/src/shell.ts | 13 ++++++++++++- packages/core/test/shell.test.ts | 17 +++++++++++++++++ packages/opencode/src/tool/shell.ts | 16 ++++++++++------ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d904..e4f17029a59a 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -163,6 +163,17 @@ function info(file: string): Item { } } +// Windows PowerShell 5.1 encodes its own output with the legacy OEM code page +// (e.g. CP936 on zh-CN systems) when stdout is redirected, while opencode +// decodes process output as UTF-8. Force UTF-8 before the user command runs. +const PS_UTF8_PREAMBLE = + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $OutputEncoding = [System.Text.UTF8Encoding]::new(); " + +export function psCommand(command: string) { + if (process.platform !== "win32") return command + return PS_UTF8_PREAMBLE + command +} + export function args(file: string, command: string, cwd: string) { const n = name(file) if (n === "nu" || n === "fish") return ["-c", command] @@ -195,7 +206,7 @@ export function args(file: string, command: string, cwd: string) { ] } if (n === "cmd") return ["/c", command] - if (ps(file)) return ["-NoProfile", "-Command", command] + if (ps(file)) return ["-NoProfile", "-Command", psCommand(command)] return ["-c", command] } diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 1cc47a79f6cd..94aa36d57e3f 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -63,6 +63,23 @@ describe("shell", () => { expect(zsh.at(-1)).toBe("/tmp") }) + test("psCommand wraps command with UTF-8 preamble on Windows", () => { + const result = Shell.psCommand("echo hi") + if (process.platform === "win32") { + expect(result.startsWith("[Console]::OutputEncoding")).toBe(true) + expect(result.endsWith("echo hi")).toBe(true) + } else { + expect(result).toBe("echo hi") + } + }) + + test("applies psCommand to PowerShell args", () => { + const psArgs = Shell.args("powershell", "echo hi", "C:\\tmp") + expect(psArgs[0]).toBe("-NoProfile") + expect(psArgs[1]).toBe("-Command") + expect(String(psArgs.at(-1))).toContain("echo hi") + }) + if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..fd83715ad234 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -292,12 +292,16 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && Shell.ps(shell)) { - return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdin: "ignore", - detached: false, - }) + return ChildProcess.make( + shell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", Shell.psCommand(command)], + { + cwd, + env, + stdin: "ignore", + detached: false, + }, + ) } return ChildProcess.make(command, [], {