Просмотр исходного кода

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>
Dax Raad 4 недель назад
Родитель
Сommit
34cfa8601f

+ 1 - 0
packages/core/src/shell.ts

@@ -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>

+ 23 - 2
packages/core/src/shell/select.ts

@@ -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 (ps(file)) return ["-NoProfile", "-Command", command]
+  if (n === "cmd") return ["/d", "/c", `chcp 65001 >nul 2>nul & ${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]
 }
 }
 
 

+ 1 - 1
packages/core/src/tool/shell.ts

@@ -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 - 0
packages/core/test/shell.test.ts

@@ -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 () => {

+ 1 - 1
packages/core/test/tool-shell.test.ts

@@ -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.",