mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 18:47:28 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user