Skip to content

Commit ff49a6c

Browse files
committed
decode Windows console output (GBK/CP936)
Add a shared Windows console encoding helper that detects the active code page via chcp and decodes captured output with TextDecoder (e.g. CP936/GBK, GB18030). Use that decoder for streamed stdout/stderr in the supervisor child adapter so Windows commands no longer appear garbled when they emit non‑UTF8 text. Apply the same decoding in runCommandWithTimeout() for non‑supervisor spawn paths. Refactor node-host to reuse the shared decoder and update tests accordingly. Harden Windows spawning in scripts/ui.js by avoiding shell: true for resolved absolute paths and routing .cmd/.bat through an explicit cmd.exe /c wrapper.
1 parent 17fd46a commit ff49a6c

File tree

6 files changed

+154
-85
lines changed

6 files changed

+154
-85
lines changed

scripts/ui.js

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export function shouldUseShellForCommand(cmd, platform = process.platform) {
5757
if (platform !== "win32") {
5858
return false;
5959
}
60+
// When `which()` resolves to an absolute path like
61+
// `C:\Program Files\nodejs\pnpm.CMD`, spawning that directly works
62+
// without `shell: true`. Using `shell: true` with such paths causes
63+
// Windows to split on the space (`C:\Program`) and fail.
64+
//
65+
// To avoid this, only route through the shell for bare command names
66+
// (no path separators). Anything that already contains a path is
67+
// executed directly.
68+
const hasPathSeparator = cmd.includes("\\") || cmd.includes("/");
69+
if (hasPathSeparator) {
70+
return false;
71+
}
6072
const extension = path.extname(cmd).toLowerCase();
6173
return WINDOWS_SHELL_EXTENSIONS.has(extension);
6274
}
@@ -69,30 +81,44 @@ export function assertSafeWindowsShellArgs(args, platform = process.platform) {
6981
if (!unsafeArg) {
7082
return;
7183
}
72-
// SECURITY: `shell: true` routes through cmd.exe; reject risky metacharacters
73-
// in forwarded args to prevent shell control-flow/env-expansion injection.
84+
// Reject risky metacharacters when we have to construct a cmd.exe
85+
// command line to avoid shell injection issues.
7486
throw new Error(
7587
`Unsafe Windows shell argument: ${unsafeArg}. Remove shell metacharacters (" & | < > ^ % !).`,
7688
);
7789
}
7890

79-
function createSpawnOptions(cmd, args, envOverride) {
80-
const useShell = shouldUseShellForCommand(cmd);
81-
if (useShell) {
82-
assertSafeWindowsShellArgs(args);
83-
}
91+
function createSpawnOptions(_cmd, _args, envOverride) {
8492
return {
8593
cwd: uiDir,
8694
stdio: "inherit",
8795
env: envOverride ?? process.env,
88-
...(useShell ? { shell: true } : {}),
8996
};
9097
}
9198

99+
function normalizeWindowsCommand(cmd, args) {
100+
if (process.platform !== "win32") {
101+
return { command: cmd, commandArgs: args };
102+
}
103+
const extension = path.extname(cmd).toLowerCase();
104+
if (!WINDOWS_SHELL_EXTENSIONS.has(extension)) {
105+
return { command: cmd, commandArgs: args };
106+
}
107+
108+
const hasPathSeparator = cmd.includes("\\") || cmd.includes("/");
109+
const quotedCmd = hasPathSeparator ? `"${cmd}"` : cmd;
110+
const commandLine = args.length > 0 ? `${quotedCmd} ${args.join(" ")}` : quotedCmd;
111+
112+
const command = process.env.comspec || "cmd.exe";
113+
const commandArgs = ["/d", "/s", "/c", commandLine];
114+
return { command, commandArgs };
115+
}
116+
92117
function run(cmd, args) {
93118
let child;
119+
const { command, commandArgs } = normalizeWindowsCommand(cmd, args);
94120
try {
95-
child = spawn(cmd, args, createSpawnOptions(cmd, args));
121+
child = spawn(command, commandArgs, createSpawnOptions(command, commandArgs));
96122
} catch (err) {
97123
console.error(`Failed to launch ${cmd}:`, err);
98124
process.exit(1);
@@ -112,8 +138,9 @@ function run(cmd, args) {
112138

113139
function runSync(cmd, args, envOverride) {
114140
let result;
141+
const { command, commandArgs } = normalizeWindowsCommand(cmd, args);
115142
try {
116-
result = spawnSync(cmd, args, createSpawnOptions(cmd, args, envOverride));
143+
result = spawnSync(command, commandArgs, createSpawnOptions(command, commandArgs, envOverride));
117144
} catch (err) {
118145
console.error(`Failed to launch ${cmd}:`, err);
119146
process.exit(1);

src/node-host/invoke.sanitize-env.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
2+
import {
3+
decodeCapturedOutputBuffer,
4+
parseWindowsCodePage,
5+
} from "../process/windows-console-encoding.js";
26
import { withEnv } from "../test-utils/env.js";
3-
import { decodeCapturedOutputBuffer, parseWindowsCodePage, sanitizeEnv } from "./invoke.js";
7+
import { sanitizeEnv } from "./invoke.js";
48
import { buildNodeInvokeResultParams } from "./runner.js";
59

610
describe("node-host sanitizeEnv", () => {

src/node-host/invoke.ts

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn, spawnSync } from "node:child_process";
1+
import { spawn } from "node:child_process";
22
import fs from "node:fs";
33
import path from "node:path";
44
import { GatewayClient } from "../gateway/client.js";
@@ -19,6 +19,10 @@ import {
1919
type ExecHostResponse,
2020
} from "../infra/exec-host.js";
2121
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
22+
import {
23+
decodeCapturedOutputBuffer,
24+
resolveWindowsConsoleEncoding,
25+
} from "../process/windows-console-encoding.js";
2226
import { runBrowserProxyCommand } from "./invoke-browser.js";
2327
import { buildSystemRunApprovalPlan, handleSystemRunInvoke } from "./invoke-system-run.js";
2428
import type {
@@ -32,16 +36,6 @@ import type {
3236
const OUTPUT_CAP = 200_000;
3337
const OUTPUT_EVENT_TAIL = 20_000;
3438
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
35-
const WINDOWS_CODEPAGE_ENCODING_MAP: Record<number, string> = {
36-
65001: "utf-8",
37-
54936: "gb18030",
38-
936: "gbk",
39-
950: "big5",
40-
932: "shift_jis",
41-
949: "euc-kr",
42-
1252: "windows-1252",
43-
};
44-
let cachedWindowsConsoleEncoding: string | null | undefined;
4539

4640
const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
4741
const execHostFallbackAllowed =
@@ -103,65 +97,6 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
10397
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
10498
}
10599

106-
export function parseWindowsCodePage(raw: string): number | null {
107-
if (!raw) {
108-
return null;
109-
}
110-
const match = raw.match(/\b(\d{3,5})\b/);
111-
if (!match?.[1]) {
112-
return null;
113-
}
114-
const codePage = Number.parseInt(match[1], 10);
115-
if (!Number.isFinite(codePage) || codePage <= 0) {
116-
return null;
117-
}
118-
return codePage;
119-
}
120-
121-
function resolveWindowsConsoleEncoding(): string | null {
122-
if (process.platform !== "win32") {
123-
return null;
124-
}
125-
if (cachedWindowsConsoleEncoding !== undefined) {
126-
return cachedWindowsConsoleEncoding;
127-
}
128-
try {
129-
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", "chcp"], {
130-
windowsHide: true,
131-
encoding: "utf8",
132-
stdio: ["ignore", "pipe", "pipe"],
133-
});
134-
const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
135-
const codePage = parseWindowsCodePage(raw);
136-
cachedWindowsConsoleEncoding =
137-
codePage !== null ? (WINDOWS_CODEPAGE_ENCODING_MAP[codePage] ?? null) : null;
138-
} catch {
139-
cachedWindowsConsoleEncoding = null;
140-
}
141-
return cachedWindowsConsoleEncoding;
142-
}
143-
144-
export function decodeCapturedOutputBuffer(params: {
145-
buffer: Buffer;
146-
platform?: NodeJS.Platform;
147-
windowsEncoding?: string | null;
148-
}): string {
149-
const utf8 = params.buffer.toString("utf8");
150-
const platform = params.platform ?? process.platform;
151-
if (platform !== "win32") {
152-
return utf8;
153-
}
154-
const encoding = params.windowsEncoding ?? resolveWindowsConsoleEncoding();
155-
if (!encoding || encoding.toLowerCase() === "utf-8") {
156-
return utf8;
157-
}
158-
try {
159-
return new TextDecoder(encoding).decode(params.buffer);
160-
} catch {
161-
return utf8;
162-
}
163-
}
164-
165100
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
166101
const socketPath = file.socket?.path?.trim();
167102
return {

src/process/exec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js";
88
import { logDebug, logError } from "../logger.js";
99
import { resolveCommandStdio } from "./spawn-utils.js";
1010
import { resolveWindowsCommandShim } from "./windows-command.js";
11+
import { resolveWindowsConsoleEncoding } from "./windows-console-encoding.js";
1112

1213
const execFileAsync = promisify(execFile);
1314

@@ -224,6 +225,15 @@ export async function runCommandWithTimeout(
224225
const finalArgv = process.platform === "win32" ? (resolveNpmArgvForWindows(argv) ?? argv) : argv;
225226
const resolvedCommand = finalArgv !== argv ? (finalArgv[0] ?? "") : resolveCommand(argv[0] ?? "");
226227
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
228+
const windowsEncoding = resolveWindowsConsoleEncoding();
229+
const stdoutDecoder =
230+
process.platform === "win32" && windowsEncoding && windowsEncoding.toLowerCase() !== "utf-8"
231+
? new TextDecoder(windowsEncoding)
232+
: null;
233+
const stderrDecoder =
234+
process.platform === "win32" && windowsEncoding && windowsEncoding.toLowerCase() !== "utf-8"
235+
? new TextDecoder(windowsEncoding)
236+
: null;
227237
const child = spawn(
228238
useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand,
229239
useCmdWrapper
@@ -290,11 +300,13 @@ export async function runCommandWithTimeout(
290300
}
291301

292302
child.stdout?.on("data", (d) => {
293-
stdout += d.toString();
303+
const chunk = d as Buffer;
304+
stdout += stdoutDecoder ? stdoutDecoder.decode(chunk, { stream: true }) : chunk.toString();
294305
armNoOutputTimer();
295306
});
296307
child.stderr?.on("data", (d) => {
297-
stderr += d.toString();
308+
const chunk = d as Buffer;
309+
stderr += stderrDecoder ? stderrDecoder.decode(chunk, { stream: true }) : chunk.toString();
298310
armNoOutputTimer();
299311
});
300312
child.on("error", (err) => {

src/process/supervisor/adapters/child.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_pr
22
import { killProcessTree } from "../../kill-tree.js";
33
import { spawnWithFallback } from "../../spawn-utils.js";
44
import { resolveWindowsCommandShim } from "../../windows-command.js";
5+
import { resolveWindowsConsoleEncoding } from "../../windows-console-encoding.js";
56
import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js";
67
import { toStringEnv } from "./env.js";
78

@@ -100,15 +101,33 @@ export async function createChildAdapter(params: {
100101
}
101102
: undefined;
102103

104+
const windowsEncoding = resolveWindowsConsoleEncoding();
105+
const stdoutDecoder =
106+
process.platform === "win32" && windowsEncoding && windowsEncoding.toLowerCase() !== "utf-8"
107+
? new TextDecoder(windowsEncoding)
108+
: null;
109+
const stderrDecoder =
110+
process.platform === "win32" && windowsEncoding && windowsEncoding.toLowerCase() !== "utf-8"
111+
? new TextDecoder(windowsEncoding)
112+
: null;
113+
103114
const onStdout = (listener: (chunk: string) => void) => {
104115
child.stdout.on("data", (chunk) => {
105-
listener(chunk.toString());
116+
if (stdoutDecoder) {
117+
listener(stdoutDecoder.decode(chunk as Buffer, { stream: true }));
118+
return;
119+
}
120+
listener((chunk as Buffer).toString());
106121
});
107122
};
108123

109124
const onStderr = (listener: (chunk: string) => void) => {
110125
child.stderr.on("data", (chunk) => {
111-
listener(chunk.toString());
126+
if (stderrDecoder) {
127+
listener(stderrDecoder.decode(chunk as Buffer, { stream: true }));
128+
return;
129+
}
130+
listener((chunk as Buffer).toString());
112131
});
113132
};
114133

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { spawnSync } from "node:child_process";
2+
3+
const WINDOWS_CODEPAGE_ENCODING_MAP: Record<number, string> = {
4+
65001: "utf-8",
5+
54936: "gb18030",
6+
936: "gbk",
7+
950: "big5",
8+
932: "shift_jis",
9+
949: "euc-kr",
10+
1252: "windows-1252",
11+
};
12+
13+
let cachedWindowsConsoleEncoding: string | null | undefined;
14+
15+
export function parseWindowsCodePage(raw: string): number | null {
16+
if (!raw) {
17+
return null;
18+
}
19+
const match = raw.match(/\b(\d{3,5})\b/);
20+
if (!match?.[1]) {
21+
return null;
22+
}
23+
const codePage = Number.parseInt(match[1], 10);
24+
if (!Number.isFinite(codePage) || codePage <= 0) {
25+
return null;
26+
}
27+
return codePage;
28+
}
29+
30+
export function resolveWindowsConsoleEncoding(): string | null {
31+
if (process.platform !== "win32") {
32+
return null;
33+
}
34+
if (cachedWindowsConsoleEncoding !== undefined) {
35+
return cachedWindowsConsoleEncoding;
36+
}
37+
try {
38+
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", "chcp"], {
39+
windowsHide: true,
40+
encoding: "utf8",
41+
stdio: ["ignore", "pipe", "pipe"],
42+
});
43+
const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
44+
const codePage = parseWindowsCodePage(raw);
45+
cachedWindowsConsoleEncoding =
46+
codePage !== null ? (WINDOWS_CODEPAGE_ENCODING_MAP[codePage] ?? null) : null;
47+
} catch {
48+
cachedWindowsConsoleEncoding = null;
49+
}
50+
return cachedWindowsConsoleEncoding;
51+
}
52+
53+
export function decodeCapturedOutputBuffer(params: {
54+
buffer: Buffer;
55+
platform?: NodeJS.Platform;
56+
windowsEncoding?: string | null;
57+
}): string {
58+
const utf8 = params.buffer.toString("utf8");
59+
const platform = params.platform ?? process.platform;
60+
if (platform !== "win32") {
61+
return utf8;
62+
}
63+
const encoding = params.windowsEncoding ?? resolveWindowsConsoleEncoding();
64+
if (!encoding || encoding.toLowerCase() === "utf-8") {
65+
return utf8;
66+
}
67+
try {
68+
return new TextDecoder(encoding).decode(params.buffer);
69+
} catch {
70+
return utf8;
71+
}
72+
}

0 commit comments

Comments
 (0)