Skip to content

Commit 97f7ce8

Browse files
committed
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.
1 parent 0d3e0fc commit 97f7ce8

4 files changed

Lines changed: 45 additions & 9 deletions

File tree

packages/core/src/shell.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { setTimeout as sleep } from "node:timers/promises"
88
import { Flag } from "./flag/flag"
99
import { FSUtil } from "./fs-util"
1010
import { which } from "./util/which"
11+
import { PowerShell } from "./shell/powershell"
1112

1213
const SIGKILL_TIMEOUT_MS = 200
1314
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
@@ -195,7 +196,7 @@ export function args(file: string, command: string, cwd: string) {
195196
]
196197
}
197198
if (n === "cmd") return ["/c", command]
198-
if (ps(file)) return ["-NoProfile", "-Command", command]
199+
if (ps(file)) return PowerShell.args(command)
199200
return ["-c", command]
200201
}
201202

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

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

@@ -46,6 +47,31 @@ const Output = Schema.Struct({
4647
type Output = typeof Output.Type
4748

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

5076
const compactOutput = (stdout: string, stderr: string) => {
5177
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
@@ -153,13 +179,7 @@ export const layer = Layer.effectDiscard(
153179
const shell =
154180
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
155181
.shell ?? defaultShell()
156-
const command = ChildProcess.make(input.command, [], {
157-
cwd: target.canonical,
158-
shell,
159-
stdin: "ignore",
160-
detached: process.platform !== "win32",
161-
forceKillAfter: Duration.seconds(3),
162-
})
182+
const command = makeShellCommand(input.command, shell, target.canonical)
163183
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
164184
const result = yield* appProcess
165185
.run(command, {

packages/opencode/src/tool/shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan,
292292

293293
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
294294
if (process.platform === "win32" && Shell.ps(shell)) {
295-
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
295+
return ChildProcess.make(shell, Shell.args(shell, command, cwd), {
296296
cwd,
297297
env,
298298
stdin: "ignore",

0 commit comments

Comments
 (0)