From 3173f45e7e8efac7f96ef608a5e7ce1091c6f7ed Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Fri, 30 Oct 2026 01:37:37 +0800 Subject: [PATCH] fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) --- packages/plugin-sdk/CHANGELOG.md | 1 + packages/plugin-sdk/src/host-commands.ts | 40 +++++++-- .../tests/plugins/host-commands.test.ts | 88 +++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 140b887b0..42e65be73 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -13,6 +13,7 @@ Plugin API 1.13.0. ### Fixed - `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path +- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters ## 1.0.21 diff --git a/packages/plugin-sdk/src/host-commands.ts b/packages/plugin-sdk/src/host-commands.ts index c131faa57..b412d3952 100644 --- a/packages/plugin-sdk/src/host-commands.ts +++ b/packages/plugin-sdk/src/host-commands.ts @@ -21,6 +21,30 @@ export interface ExecResult { code: number | null; } +/** + * Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK, + * which is what a Chinese-locale Windows console sends (its bytes are almost + * never valid UTF-8) and keeps it only when it decodes without replacement + * characters. Anything else falls back to lossy UTF-8, the old behavior. + */ +export function decodeCommandOutput(buf: Buffer): string { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + buf, + ); + } catch { + // not valid UTF-8, try the legacy codepage + } + try { + // A Node built with small-ICU has no GBK decoder and throws here. + const gbk = new TextDecoder("gbk").decode(buf); + if (!gbk.includes("�")) return gbk; + } catch { + // expected - fall through to lossy UTF-8 + } + return buf.toString("utf8"); +} + /** Runs one non-interactive command over an existing ssh2 client. */ export function execCommand( client: Client, @@ -65,8 +89,10 @@ export function execCommand( } stream = _stream; - let stdout = ""; - let stderr = ""; + // Chunks are buffered and decoded once on close: a multi-byte character + // can be split across chunks, and charset detection needs it whole. + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; let exitCode: number | null = null; stream @@ -75,11 +101,15 @@ export function execCommand( settled = true; exitCode = typeof code === "number" ? code : null; cleanup(); - resolve({ stdout, stderr, code: exitCode }); + resolve({ + stdout: decodeCommandOutput(Buffer.concat(stdout)), + stderr: decodeCommandOutput(Buffer.concat(stderr)), + code: exitCode, + }); } }) .on("data", (data: Buffer) => { - stdout += data.toString("utf8"); + stdout.push(data); }) .on("error", (streamErr: Error) => { if (!settled) { @@ -92,7 +122,7 @@ export function execCommand( if (stream.stderr) { stream.stderr .on("data", (data: Buffer) => { - stderr += data.toString("utf8"); + stderr.push(data); }) .on("error", (stderrErr: Error) => { if (!settled) { diff --git a/src/backend/tests/plugins/host-commands.test.ts b/src/backend/tests/plugins/host-commands.test.ts index 9d27a2783..07d2d5490 100644 --- a/src/backend/tests/plugins/host-commands.test.ts +++ b/src/backend/tests/plugins/host-commands.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { describe, it, expect } from "vitest"; import { parsePlatformProbe, @@ -7,6 +8,7 @@ import { buildSudoCommand, parseUpgradable, execCommand, + decodeCommandOutput, } from "@termix-ssh/plugin-sdk/host-commands"; describe("parsePlatformProbe", () => { @@ -116,6 +118,32 @@ describe("parseUpgradable", () => { }); }); +class FakeStream extends EventEmitter { + stderr = new EventEmitter(); + destroy() {} +} + +function fakeClient(script: (stream: FakeStream) => void) { + return { + exec: ( + _command: string, + _options: unknown, + callback: (err: Error | undefined, stream: FakeStream) => void, + ) => { + const stream = new FakeStream(); + callback(undefined, stream); + script(stream); + }, + } as never; +} + +// "The system cannot find the path specified." as the GBK bytes a +// Chinese-locale Windows console actually sends. +const GBK_PATH_ERROR = Buffer.from( + "cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3", + "hex", +); + describe("execCommand", () => { it("leaves the command out of the timeout error", async () => { const client = { exec: () => {} } as never; @@ -124,4 +152,64 @@ describe("execCommand", () => { /^Command timeout after 5ms$/, ); }); + + it("decodes GBK stderr from a localized Windows host", async () => { + const result = await execCommand( + fakeClient((stream) => { + stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7)); + stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7)); + stream.emit("close", 1); + }), + "dir", + ); + expect(result.stderr).toBe("系统找不到指定的路径。"); + expect(result.code).toBe(1); + }); + + it("reassembles a UTF-8 character split across chunks", async () => { + const utf8 = Buffer.from("以太网 2", "utf8"); + const result = await execCommand( + fakeClient((stream) => { + stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half + stream.emit("data", utf8.subarray(4)); + stream.emit("close", 0); + }), + "ipconfig", + ); + expect(result.stdout).toBe("以太网 2"); + }); + + it("passes plain ASCII output through unchanged", async () => { + const result = await execCommand( + fakeClient((stream) => { + stream.emit("data", Buffer.from("ok\n")); + stream.stderr.emit("data", Buffer.from("warn\n")); + stream.emit("close", 0); + }), + "echo ok", + ); + expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 }); + }); +}); + +describe("decodeCommandOutput", () => { + it("keeps valid UTF-8, including the empty buffer", () => { + expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe( + "以太网 2", + ); + expect(decodeCommandOutput(Buffer.alloc(0))).toBe(""); + }); + + it("falls back to GBK when the bytes are not valid UTF-8", () => { + // "以太网 2" in GBK, the interface name of the metrics widget issue. + expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe( + "以太网 2", + ); + expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。"); + }); + + it("keeps lossy UTF-8 when no decoder matches", () => { + // 0xff is a valid byte in neither UTF-8 nor GBK. + expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�"); + }); });