Compare commits

...

2 Commits

Author SHA1 Message Date
Dax Raad 7632b49d28 fix(core): preserve PowerShell exit codes
Capture native and PowerShell command status after the UTF-8 wrapper so failing commands still settle with a non-zero exit.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 04:58:06 +00:00
Dax Raad 34cfa8601f 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>
2026-07-13 03:48:24 +00:00
5 changed files with 47 additions and 4 deletions
+1
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>
+27 -2
View File
@@ -163,12 +163,37 @@ 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}'))))`,
"$success = $?",
"$code = $LASTEXITCODE",
"if ($null -ne $code) { exit $code }",
"if (-not $success) { exit 1 }",
].join("; ")
return [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
Buffer.from(script, "utf16le").toString("base64"),
]
}
return ["-c", command] return ["-c", command]
} }
+1 -1
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.
+17
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')))); $success = $?; $code = $LASTEXITCODE; if ($null -ne $code) { exit $code }; if (-not $success) { exit 1 }",
)
})
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 () => {
+1 -1
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.",