Skip to content

Commit 727d78a

Browse files
senguangdclaude
andcommitted
fix(shell): use PowerShell EncodedCommand for reliable UTF-8 output on Windows
On Windows, PowerShell commands were passed via -Command flag, causing encoding corruption when the active console code page is not UTF-8 (e.g. GBK/CP936 on zh-CN systems, Shift-JIS/CP932 on ja-JP systems). The UTF-8 preamble added by previous attempts runs too late because -Command parses the string in the current code page before execution. This change switches to -EncodedCommand with Base64(UTF-16LE) encoding, which guarantees the command string survives transport intact regardless of the console code page. The original command is base64-encoded (UTF-8) and embedded in the payload, then decoded at runtime via [scriptblock]::Create() after the UTF-8 preamble has already taken effect. This avoids prepending statements before the user command, which would break param() blocks and #requires directives. The encoding logic is extracted into a shared module (core/shell/powershell.ts) used by all three PowerShell execution paths: - packages/core/src/tool/bash.ts (core bash tool) - packages/opencode/src/tool/shell.ts (opencode shell tool) - packages/opencode/src/shell/shell.ts (TUI direct shell mode) Closes #23636 Closes #31187 Closes #30205 Closes #31830 Closes #26882 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent dbbe67f commit 727d78a

4 files changed

Lines changed: 48 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export * as PowerShell from "./powershell"
2+
3+
export function args(command: string) {
4+
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded(command)]
5+
}
6+
7+
function encoded(command: string) {
8+
const payload = Buffer.from(command, "utf8").toString("base64")
9+
return Buffer.from(
10+
`
11+
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
12+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
13+
$OutputEncoding = [Console]::OutputEncoding;
14+
& ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${payload}'))))
15+
`,
16+
"utf16le",
17+
).toString("base64")
18+
}

packages/core/src/tool/bash.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { LocationMutation } from "../location-mutation"
1010
import { AppProcess } from "../process"
1111
import { PermissionV2 } from "../permission"
1212
import { PositiveInt } from "../schema"
13+
import { PowerShell } from "../shell/powershell"
1314
import { Tool } from "./tool"
1415
import { Tools } from "./tools"
1516

@@ -49,6 +50,31 @@ const Output = Schema.Struct({
4950
type Output = typeof Output.Type
5051

5152
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
53+
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"])
54+
55+
const isPowerShell = (shell: string) => {
56+
const name = path.basename(shell.trim().replace(/^["']|["']$/g, "")).toLowerCase()
57+
return POWERSHELL_SHELLS.has(name)
58+
}
59+
60+
function makeShellCommand(command: string, shell: string, cwd: string) {
61+
if (process.platform === "win32" && isPowerShell(shell)) {
62+
return ChildProcess.make(shell, PowerShell.args(command), {
63+
cwd,
64+
stdin: "ignore",
65+
detached: false,
66+
forceKillAfter: Duration.seconds(3),
67+
})
68+
}
69+
70+
return ChildProcess.make(command, [], {
71+
cwd,
72+
shell,
73+
stdin: "ignore",
74+
detached: process.platform !== "win32",
75+
forceKillAfter: Duration.seconds(3),
76+
})
77+
}
5278

5379
const compactOutput = (stdout: string, stderr: string) => {
5480
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
@@ -156,13 +182,7 @@ export const layer = Layer.effectDiscard(
156182
const shell =
157183
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
158184
.shell ?? defaultShell()
159-
const command = ChildProcess.make(input.command, [], {
160-
cwd: target.canonical,
161-
shell,
162-
stdin: "ignore",
163-
detached: process.platform !== "win32",
164-
forceKillAfter: Duration.seconds(3),
165-
})
185+
const command = makeShellCommand(input.command, shell, target.canonical)
166186
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
167187
const result = yield* appProcess
168188
.run(command, {

packages/opencode/src/shell/shell.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Flag } from "@opencode-ai/core/flag/flag"
2+
import { PowerShell } from "@opencode-ai/core/shell/powershell"
23
import { lazy } from "@/util/lazy"
34
import { Filesystem } from "@/util/filesystem"
45
import { which } from "@opencode-ai/core/util/which"
@@ -188,7 +189,7 @@ export function args(file: string, command: string, cwd: string) {
188189
]
189190
}
190191
if (n === "cmd") return ["/c", command]
191-
if (ps(file)) return ["-NoProfile", "-Command", command]
192+
if (ps(file)) return PowerShell.args(command)
192193
return ["-c", command]
193194
}
194195

packages/opencode/src/tool/shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (
298298

299299
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
300300
if (process.platform === "win32" && Shell.ps(shell)) {
301-
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
301+
return ChildProcess.make(shell, Shell.args(shell, command, cwd), {
302302
cwd,
303303
env,
304304
stdin: "ignore",

0 commit comments

Comments
 (0)