From 0689b9d54d28b829787bf2811977651581ba58df Mon Sep 17 00:00:00 2001 From: Senguang Ding Date: Tue, 16 Jun 2026 18:05:53 +0800 Subject: [PATCH] fix(shell): add PowerShell command wrapper for UTF-8 encoding On Windows, PowerShell commands need proper UTF-8 encoding setup to avoid corruption when the console code page is not UTF-8 (e.g. GBK/CP936 on zh-CN systems). This adds a shared PowerShell module that: - Sets [Console]::InputEncoding and OutputEncoding to UTF-8 - Uses inner Base64 + [scriptblock]::Create() to preserve user command semantics (param(), #requires must be at script start) Used by bash tool, shell tool, and TUI direct shell mode. --- packages/core/src/shell.ts | 3 ++- packages/core/src/shell/powershell.ts | 15 ++++++++++++ packages/core/src/tool/bash.ts | 34 +++++++++++++++++++++------ packages/opencode/src/tool/shell.ts | 2 +- 4 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/shell/powershell.ts diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d904..4f16d36b3bb1 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -8,6 +8,7 @@ import { setTimeout as sleep } from "node:timers/promises" import { Flag } from "./flag/flag" import { FSUtil } from "./fs-util" import { which } from "./util/which" +import { PowerShell } from "./shell/powershell" const SIGKILL_TIMEOUT_MS = 200 const META: Record = { @@ -195,7 +196,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 PowerShell.args(command) return ["-c", command] } diff --git a/packages/core/src/shell/powershell.ts b/packages/core/src/shell/powershell.ts new file mode 100644 index 000000000000..1d91ef468b02 --- /dev/null +++ b/packages/core/src/shell/powershell.ts @@ -0,0 +1,15 @@ +export * as PowerShell from "./powershell" + +export function args(command: string) { + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", wrapped(command)] +} + +function wrapped(command: string) { + const payload = Buffer.from(command, "utf8").toString("base64") + return ` +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); +$OutputEncoding = [Console]::OutputEncoding; +& ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${payload}')))) +` +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175adae0..4d058e25e673 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -10,6 +10,7 @@ import { LocationMutation } from "../location-mutation" import { AppProcess } from "../process" import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" +import { PowerShell } from "../shell/powershell" import { Tool } from "./tool" import { Tools } from "./tools" @@ -49,6 +50,31 @@ const Output = Schema.Struct({ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") +const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]) + +const isPowerShell = (shell: string) => { + const name = path.basename(shell.trim().replace(/^["']|["']$/g, "")).toLowerCase() + return POWERSHELL_SHELLS.has(name) +} + +function makeShellCommand(command: string, shell: string, cwd: string) { + if (process.platform === "win32" && isPowerShell(shell)) { + return ChildProcess.make(shell, PowerShell.args(command), { + cwd, + stdin: "ignore", + detached: false, + forceKillAfter: Duration.seconds(3), + }) + } + + return ChildProcess.make(command, [], { + cwd, + shell, + stdin: "ignore", + detached: process.platform !== "win32", + forceKillAfter: Duration.seconds(3), + }) +} const compactOutput = (stdout: string, stderr: string) => { const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout @@ -156,13 +182,7 @@ export const layer = Layer.effectDiscard( const shell = Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) .shell ?? defaultShell() - const command = ChildProcess.make(input.command, [], { - cwd: target.canonical, - shell, - stdin: "ignore", - detached: process.platform !== "win32", - forceKillAfter: Duration.seconds(3), - }) + const command = makeShellCommand(input.command, shell, target.canonical) const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 620378dc1044..316e8eb93330 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -298,7 +298,7 @@ const ask = Effect.fn("ShellTool.ask")(function* ( 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], { + return ChildProcess.make(shell, Shell.args(shell, command, cwd), { cwd, env, stdin: "ignore",