fix(core): force UTF-8 for Windows shells

Force cmd, PowerShell, and piped Python subprocesses to emit UTF-8 so V2 shell output is decoded without CJK mojibake.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Dax Raad 2026-07-13 03:48:24 +00:00
commit 34cfa8601f
5 changed files with 43 additions and 4 deletions

View file

@ -173,6 +173,7 @@ export const layer = Layer.effect(
const file = path.join(outputDir, `${id}.out`) const file = path.join(outputDir, `${id}.out`)
const env = { const env = {
...process.env, ...process.env,
...ShellSelect.env(),
TERM: "xterm-256color", TERM: "xterm-256color",
OPENCODE_TERMINAL: "1", OPENCODE_TERMINAL: "1",
} as Record<string, string> } as Record<string, string>

View file

@ -163,12 +163,33 @@ function info(file: string): Item {
} }
} }
export function env(platform = process.platform) {
if (platform === "win32") return { PYTHONIOENCODING: "utf-8" }
return {}
}
export function args(file: string, command: string) { export function args(file: string, command: string) {
const n = name(file) const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command] if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh" || n === "bash") return ["-c", command] if (n === "zsh" || n === "bash") return ["-c", command]
if (n === "cmd") return ["/c", command] if (n === "cmd") return ["/d", "/c", `chcp 65001 >nul 2>nul & ${command}`]
if (ps(file)) return ["-NoProfile", "-Command", command] if (ps(file)) {
const payload = Buffer.from(command, "utf8").toString("base64")
const script = [
"$utf8 = [System.Text.UTF8Encoding]::new($false)",
"[Console]::InputEncoding = $utf8",
"[Console]::OutputEncoding = $utf8",
"$OutputEncoding = $utf8",
`& ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${payload}'))))`,
].join("; ")
return [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
Buffer.from(script, "utf16le").toString("base64"),
]
}
return ["-c", command] return ["-c", command]
} }

View file

@ -70,7 +70,7 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction. // TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals. // TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection. // TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Restore Windows shell-specific path handling.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. // TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
// TODO: Persist job status and define restart recovery before exposing remote observation. // TODO: Persist job status and define restart recovery before exposing remote observation.

View file

@ -61,6 +61,23 @@ describe("shell", () => {
expect(ShellSelect.args("/bin/bash", "echo hi")).toEqual(["-c", "echo hi"]) expect(ShellSelect.args("/bin/bash", "echo hi")).toEqual(["-c", "echo hi"])
}) })
test("switches cmd output to UTF-8", () => {
expect(ShellSelect.args("cmd", 'echo "你好"')).toEqual(["/d", "/c", 'chcp 65001 >nul 2>nul & echo "你好"'])
})
test("transports PowerShell commands independently of the active code page", () => {
const args = ShellSelect.args("pwsh", 'Write-Output "你好"')
expect(args.slice(0, 4)).toEqual(["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand"])
expect(Buffer.from(args[4], "base64").toString("utf16le")).toBe(
"$utf8 = [System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding = $utf8; [Console]::OutputEncoding = $utf8; $OutputEncoding = $utf8; & ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('V3JpdGUtT3V0cHV0ICLkvaDlpb0i'))))",
)
})
test("forces piped Python output to UTF-8 on Windows", () => {
expect(ShellSelect.env("win32")).toEqual({ PYTHONIOENCODING: "utf-8" })
expect(ShellSelect.env("linux")).toEqual({})
})
if (process.platform === "win32") { if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => { test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => { await withShell("NU.EXE", async () => {

View file

@ -570,7 +570,7 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Port tree-sitter bash / PowerShell parser-based approval reduction.", "Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.", "Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.", "Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Restore Windows shell-specific path handling.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist job status and define restart recovery before exposing remote observation.", "Persist job status and define restart recovery before exposing remote observation.",