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>
This commit is contained in:
Sid
2026-03-02 21:50:17 +08:00
committed by GitHub
parent 6e008e93be
commit 7b5a410b83
2 changed files with 114 additions and 7 deletions

View File

@@ -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("<22>");
return;
}
expect(decoded).toBe("测试~;");
});
});
describe("buildNodeInvokeResultParams", () => {
it("omits optional fields when null/undefined", () => {
const params = buildNodeInvokeResultParams(

View File

@@ -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<number, string> = {
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<RunResult> {
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,