From 7b5a410b831367fc9b204e78758de9b3c1aff95d Mon Sep 17 00:00:00 2001 From: Sid Date: Mon, 2 Mar 2026 21:50:17 +0800 Subject: [PATCH] fix(node-host): decode Windows exec output with active code page (openclaw#30652) thanks @Sid-Qin Verified: - pnpm vitest run src/node-host/invoke.sanitize-env.test.ts src/node-host/invoke-system-run.test.ts Co-authored-by: Sid-Qin <53659198+Sid-Qin@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- src/node-host/invoke.sanitize-env.test.ts | 32 +++++++- src/node-host/invoke.ts | 89 +++++++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index dfa44ccd0c2..aa55a24047e 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { sanitizeEnv } from "./invoke.js"; +import { decodeCapturedOutputBuffer, parseWindowsCodePage, sanitizeEnv } from "./invoke.js"; import { buildNodeInvokeResultParams } from "./runner.js"; describe("node-host sanitizeEnv", () => { @@ -53,6 +53,36 @@ describe("node-host sanitizeEnv", () => { }); }); +describe("node-host output decoding", () => { + it("parses code pages from chcp output text", () => { + expect(parseWindowsCodePage("Active code page: 936")).toBe(936); + expect(parseWindowsCodePage("活动代码页: 65001")).toBe(65001); + expect(parseWindowsCodePage("no code page")).toBeNull(); + }); + + it("decodes GBK output on Windows when code page is known", () => { + let supportsGbk = true; + try { + void new TextDecoder("gbk"); + } catch { + supportsGbk = false; + } + + const raw = Buffer.from([0xb2, 0xe2, 0xca, 0xd4, 0xa1, 0xab, 0xa3, 0xbb]); + const decoded = decodeCapturedOutputBuffer({ + buffer: raw, + platform: "win32", + windowsEncoding: "gbk", + }); + + if (!supportsGbk) { + expect(decoded).toContain("�"); + return; + } + expect(decoded).toBe("测试~;"); + }); +}); + describe("buildNodeInvokeResultParams", () => { it("omits optional fields when null/undefined", () => { const params = buildNodeInvokeResultParams( diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 11baa45e780..5d2fdd3d15c 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { GatewayClient } from "../gateway/client.js"; @@ -31,6 +31,16 @@ import type { const OUTPUT_CAP = 200_000; const OUTPUT_EVENT_TAIL = 20_000; const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const WINDOWS_CODEPAGE_ENCODING_MAP: Record = { + 65001: "utf-8", + 54936: "gb18030", + 936: "gbk", + 950: "big5", + 932: "shift_jis", + 949: "euc-kr", + 1252: "windows-1252", +}; +let cachedWindowsConsoleEncoding: string | null | undefined; const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostFallbackAllowed = @@ -92,6 +102,65 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true }; } +export function parseWindowsCodePage(raw: string): number | null { + if (!raw) { + return null; + } + const match = raw.match(/\b(\d{3,5})\b/); + if (!match?.[1]) { + return null; + } + const codePage = Number.parseInt(match[1], 10); + if (!Number.isFinite(codePage) || codePage <= 0) { + return null; + } + return codePage; +} + +function resolveWindowsConsoleEncoding(): string | null { + if (process.platform !== "win32") { + return null; + } + if (cachedWindowsConsoleEncoding !== undefined) { + return cachedWindowsConsoleEncoding; + } + try { + const result = spawnSync("cmd.exe", ["/d", "/s", "/c", "chcp"], { + windowsHide: true, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const codePage = parseWindowsCodePage(raw); + cachedWindowsConsoleEncoding = + codePage !== null ? (WINDOWS_CODEPAGE_ENCODING_MAP[codePage] ?? null) : null; + } catch { + cachedWindowsConsoleEncoding = null; + } + return cachedWindowsConsoleEncoding; +} + +export function decodeCapturedOutputBuffer(params: { + buffer: Buffer; + platform?: NodeJS.Platform; + windowsEncoding?: string | null; +}): string { + const utf8 = params.buffer.toString("utf8"); + const platform = params.platform ?? process.platform; + if (platform !== "win32") { + return utf8; + } + const encoding = params.windowsEncoding ?? resolveWindowsConsoleEncoding(); + if (!encoding || encoding.toLowerCase() === "utf-8") { + return utf8; + } + try { + return new TextDecoder(encoding).decode(params.buffer); + } catch { + return utf8; + } +} + function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); return { @@ -126,12 +195,13 @@ async function runCommand( timeoutMs: number | undefined, ): Promise { return await new Promise((resolve) => { - let stdout = ""; - let stderr = ""; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; let outputLen = 0; let truncated = false; let timedOut = false; let settled = false; + const windowsEncoding = resolveWindowsConsoleEncoding(); const child = spawn(argv[0], argv.slice(1), { cwd, @@ -147,12 +217,11 @@ async function runCommand( } const remaining = OUTPUT_CAP - outputLen; const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; - const str = slice.toString("utf8"); outputLen += slice.length; if (target === "stdout") { - stdout += str; + stdoutChunks.push(slice); } else { - stderr += str; + stderrChunks.push(slice); } if (chunk.length > remaining) { truncated = true; @@ -182,6 +251,14 @@ async function runCommand( if (timer) { clearTimeout(timer); } + const stdout = decodeCapturedOutputBuffer({ + buffer: Buffer.concat(stdoutChunks), + windowsEncoding, + }); + const stderr = decodeCapturedOutputBuffer({ + buffer: Buffer.concat(stderrChunks), + windowsEncoding, + }); resolve({ exitCode, timedOut,