fix(daemon): gateway fails to launch on Windows when the profile path contains CJK characters (#107751)

* fix(daemon): write Windows gateway launchers in encodings wscript/cmd can decode

gateway.vbs and gateway.cmd were written as UTF-8 without BOM, but
wscript.exe only reads .vbs as ANSI or UTF-16 LE with BOM and cmd.exe
reads .cmd in the console OEM code page, so installs under CJK profile
paths failed with "file not found" (#107416).

Write .vbs as UTF-16 LE with BOM, write non-ASCII .cmd content in the
system code page when it matches the console page (CJK/Thai locales),
and BOM-sniff plus code-page-fallback on read so launchers from older
installs keep parsing and migrate on refresh. The hidden .vbs launch
path originates from #95480, which addressed console visibility only.

* refactor(daemon): drop unused WindowsLauncherScriptFormat export

The type is only referenced by encodeWindowsLauncherScript's format
parameter within the module, so the export tripped check-deadcode-exports.
Keep it module-local.

* fix(daemon): mark code-page cmd launchers with their encoding for deterministic readback

Prepend an ASCII '@rem openclaw-launcher-encoding=<label>' line to code-page
.cmd launchers and decode by that marker instead of sniffing UTF-8. Some GBK
byte sequences are valid UTF-8 (隆 = C2 A1 reads as ¡), so the old sniff
silently corrupted readback and rejected valid paths; the marker makes decode
deterministic and drops the code-page probe (a PowerShell spawn) from the
frequent readScheduledTaskCommand poll path.

Also fix the representability guard for euc-kr: Node ICU decodes euc-kr as
KS X 1001 only, but Windows code page 949 is cp949/UHC, so the TextDecoder
cross-check false-rejected ~8,800 UHC extension syllables (똠 = 8C 63) that
iconv encodes and cmd.exe reads fine. Verify euc-kr via iconv's own cp949
round-trip; keep TextDecoder for the other five labels.

* fix(infra): write Windows restart helper scripts through the launcher encoder

The update-time restart helper wrote its temp .cmd as raw UTF-8 while
embedding the restart-log path, task name, and task script path, so a CJK
profile path or task name broke the same way as the gateway launchers
(#107416). Route the write through encodeWindowsLauncherScript: ASCII content
stays byte-identical UTF-8, CJK content gets the marked code-page encoding, and
an unrepresentable task name now fails the restart attempt cleanly instead of
writing a script cmd.exe would misread.

* chore(deps): minimize pnpm-lock delta for the iconv-lite promotion

Reset pnpm-lock.yaml to origin/main and re-add only the iconv-lite root
importer entry, dropping the unrelated @types/node peer-context flips and
audio-decode deprecation metadata that a mismatched-toolchain regeneration had
pulled in. The diff vs main is now the three-line importer entry only; the
version already resolves in main's tree via express -> body-parser/raw-body.

* refactor(windows): centralize launcher encoding

Co-authored-by: Jason Yao <wsyjh8@gmail.com>

* style(windows): format launcher encoding test

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
Jason
2026-07-16 03:42:56 -04:00
committed by GitHub
parent b23292cc3f
commit ccec0224fa
13 changed files with 563 additions and 27 deletions
+1
View File
@@ -40,6 +40,7 @@
"grammy": "1.44.0",
"highlight.js": "11.11.1",
"hosted-git-info": "10.1.1",
"iconv-lite": "0.7.2",
"ignore": "7.0.5",
"jiti": "2.7.0",
"json5": "2.2.3",
+1
View File
@@ -2034,6 +2034,7 @@
"grammy": "1.44.0",
"highlight.js": "11.11.1",
"hosted-git-info": "10.1.1",
"iconv-lite": "0.7.2",
"ignore": "7.0.5",
"jiti": "2.7.0",
"json5": "2.2.3",
+3
View File
@@ -136,6 +136,9 @@ importers:
hosted-git-info:
specifier: 10.1.1
version: 10.1.1
iconv-lite:
specifier: 0.7.2
version: 0.7.2
ignore:
specifier: 7.0.5
version: 7.0.5
+63 -5
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
import {
installScheduledTask,
readScheduledTaskCommand,
@@ -13,6 +14,19 @@ import {
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
import { buildServiceEnvironment } from "./service-env.js";
const resolveWindowsSystemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
// Pin code page detection so launcher encoding never depends on the host ACP.
vi.mock("../infra/windows-encoding.js", async () => {
const actual = await vi.importActual<typeof import("../infra/windows-encoding.js")>(
"../infra/windows-encoding.js",
);
return {
...actual,
resolveWindowsSystemEncoding: () => resolveWindowsSystemEncodingMock(),
};
});
const schtasksCalls: string[][] = [];
const schtasksResponses: { code: number; stdout: string; stderr: string }[] = [];
// Captures the XML payload at /Create /XML time before the production code's
@@ -45,6 +59,8 @@ beforeEach(() => {
schtasksCalls.length = 0;
schtasksResponses.length = 0;
xmlPayloadCaptures.length = 0;
resolveWindowsSystemEncodingMock.mockReset();
resolveWindowsSystemEncodingMock.mockReturnValue(null);
});
describe("installScheduledTask", () => {
@@ -115,7 +131,7 @@ describe("installScheduledTask", () => {
},
});
const script = await fs.readFile(scriptPath, "utf8");
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
expect(script).toContain('cd /d "C:\\temp\\poc&calc"');
expect(script).toContain(
'node gateway.js --display-name "safe&whoami" --percent "%%TEMP%%" --bang "^!token^!"',
@@ -228,9 +244,12 @@ describe("installScheduledTask", () => {
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
});
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
const launcher = await fs.readFile(launcherPath, "utf8");
const rawLauncher = await fs.readFile(launcherPath);
const launcher = decodeWindowsLauncherScript({ buffer: rawLauncher });
expectInitialTaskQueries();
// wscript only accepts UTF-16 LE with BOM or ANSI; UTF-16 keeps CJK paths intact.
expect(rawLauncher.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
// `/Create /XML` argv shape: ["/Create", "/F", "/TN", "<name>", "/XML", "<path>", "/RU", "<user>", "/NP"].
// The XML payload is what carries the SC, RL, TR, and battery settings now.
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
@@ -248,6 +267,45 @@ describe("installScheduledTask", () => {
});
});
it("writes hidden launchers wscript can decode for CJK profile paths (#107416)", async () => {
await withUserProfileDir(async (tmpDir, _env) => {
const cjkProfileDir = path.join(tmpDir, "苗振");
await fs.mkdir(cjkProfileDir, { recursive: true });
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
const { scriptPath } = await installDefaultGatewayTask({
USERPROFILE: cjkProfileDir,
OPENCLAW_PROFILE: "default",
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
});
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
const rawLauncher = await fs.readFile(launcherPath);
expect(scriptPath).toContain("苗振");
expect(rawLauncher.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
expect(rawLauncher.subarray(2).toString("utf16le")).toContain(
`Run """${scriptPath}""", 0, False`,
);
});
});
it("fails the install instead of writing an unrepresentable cmd launcher", async () => {
await withUserProfileDir(async (_tmpDir, env) => {
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
await expect(
installScheduledTask({
env,
stdout: new PassThrough(),
programArguments: ["node", "gateway.js"],
environment: { OC_LABEL: "🚀" },
}),
).rejects.toThrow(/cannot be represented in the Windows system code page/);
await expect(fs.access(resolveTaskScriptPath(env))).rejects.toThrow();
});
});
it("uses the hidden launcher for generated Windows gateway service installs", async () => {
await withUserProfileDir(async (_tmpDir, env) => {
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
@@ -279,8 +337,8 @@ describe("installScheduledTask", () => {
},
});
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
const script = await fs.readFile(scriptPath, "utf8");
const launcher = await fs.readFile(launcherPath, "utf8");
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
const launcher = decodeWindowsLauncherScript({ buffer: await fs.readFile(launcherPath) });
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
"/Create",
@@ -515,7 +573,7 @@ describe("installScheduledTask", () => {
},
});
const script = await fs.readFile(scriptPath, "utf8");
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
expect(script).not.toContain('set "PATH=');
expect(script).toContain('set "OPENCLAW_GATEWAY_PORT=18789"');
});
+20 -8
View File
@@ -8,6 +8,7 @@ import {
getWindowsCmdExePath,
getWindowsPowerShellExePath,
} from "../infra/windows-install-roots.js";
import { decodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
import "./test-helpers/schtasks-base-mocks.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import {
@@ -375,7 +376,9 @@ describe("Windows startup fallback", () => {
const result = await installGatewayScheduledTask(env, stdout);
const startupEntryPath = resolveStartupEntryPath(env);
const startupScript = await fs.readFile(startupEntryPath, "utf8");
const startupScript = decodeWindowsLauncherScript({
buffer: await fs.readFile(startupEntryPath),
});
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
expect(startupScript).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
expect(startupScript).toContain("gateway.cmd");
@@ -397,8 +400,11 @@ describe("Windows startup fallback", () => {
});
const startupEntryPath = resolveStartupEntryPath(env, "vbs");
const startupScript = await fs.readFile(startupEntryPath, "utf8");
const rawStartupScript = await fs.readFile(startupEntryPath);
const startupScript = decodeWindowsLauncherScript({ buffer: rawStartupScript });
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
// wscript only accepts UTF-16 LE with BOM or ANSI; UTF-16 keeps CJK paths intact.
expect(rawStartupScript.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
expect(startupScript).toContain("WScript.Shell");
expect(startupScript).toContain("gateway.cmd");
expect(startupScript).toContain(`Run """${result.scriptPath}""", 0, False`);
@@ -860,7 +866,7 @@ describe("Windows startup fallback", () => {
const startupEntryPath = await writeStartupFallbackEntry(env);
await writeGatewayScript(env, 18789);
const scriptPath = resolveTaskScriptPath(env);
const scriptBefore = await fs.readFile(scriptPath, "utf8");
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
env.OPENCLAW_GATEWAY_PORT = "19433";
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
spawnSync.mockImplementation((command, args) => {
@@ -923,7 +929,9 @@ describe("Windows startup fallback", () => {
);
expect(oldPidKills).toHaveLength(0);
expect(schtasksResponses).toHaveLength(pendingSchtasksResponses);
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
scriptBefore,
);
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
});
});
@@ -933,7 +941,7 @@ describe("Windows startup fallback", () => {
const startupEntryPath = await writeStartupFallbackEntry(env);
await writeGatewayScript(env, 18789);
const scriptPath = resolveTaskScriptPath(env);
const scriptBefore = await fs.readFile(scriptPath, "utf8");
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
env.OPENCLAW_GATEWAY_PORT = "19433";
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
spawnSync.mockImplementation((command, args) =>
@@ -974,7 +982,9 @@ describe("Windows startup fallback", () => {
"replacement gateway port 19433 is occupied by an unverified process",
);
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
scriptBefore,
);
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
});
});
@@ -984,7 +994,7 @@ describe("Windows startup fallback", () => {
const startupEntryPath = await writeStartupFallbackEntry(env);
await writeGatewayScript(env, 18789);
const scriptPath = resolveTaskScriptPath(env);
const scriptBefore = await fs.readFile(scriptPath, "utf8");
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
env.OPENCLAW_GATEWAY_PORT = "19433";
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
spawnSync.mockImplementation((command, args) =>
@@ -1014,7 +1024,9 @@ describe("Windows startup fallback", () => {
"Could not verify replacement gateway port 19433",
);
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
scriptBefore,
);
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
});
});
+66 -1
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { encodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
import {
readScheduledTaskCommand,
readScheduledTaskRuntime,
@@ -198,6 +199,7 @@ describe("readScheduledTaskCommand", () => {
async function withScheduledTaskScript(
options: {
scriptLines?: string[];
scriptEncoding?: "utf8" | "gbk";
env?:
| Record<string, string | undefined>
| ((tmpDir: string) => Record<string, string | undefined>);
@@ -214,8 +216,19 @@ describe("readScheduledTaskCommand", () => {
};
if (options.scriptLines) {
const scriptPath = resolveTaskScriptPath(env);
const script = options.scriptLines.join("\r\n");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(scriptPath, options.scriptLines.join("\r\n"), "utf8");
await fs.writeFile(
scriptPath,
options.scriptEncoding === "gbk"
? // Production bytes for a code-page install: marker line + GBK body.
encodeWindowsLauncherScript({
format: "cmd",
content: script,
windowsEncoding: "gbk",
})
: Buffer.from(script, "utf8"),
);
}
await run(env);
} finally {
@@ -239,6 +252,58 @@ describe("readScheduledTaskCommand", () => {
);
});
it("reads legacy UTF-8 scripts with CJK paths written before the encoding fix", async () => {
await withScheduledTaskScript(
{
scriptLines: ["@echo off", 'cd /d "C:\\Users\\苗振\\.openclaw"', "node gateway.js"],
},
async (env) => {
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
workingDirectory: "C:\\Users\\苗振\\.openclaw",
sourcePath: resolveTaskScriptPath(env),
});
},
);
});
it("reads marked ANSI scripts with CJK paths under a CJK code page (#107416)", async () => {
await withScheduledTaskScript(
{
scriptLines: ["@echo off", 'cd /d "C:\\Users\\苗振\\.openclaw"', "node gateway.js"],
scriptEncoding: "gbk",
},
async (env) => {
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
workingDirectory: "C:\\Users\\苗振\\.openclaw",
sourcePath: resolveTaskScriptPath(env),
});
},
);
});
it("reads back GBK launchers whose bytes are also valid UTF-8 (隆) without corruption", async () => {
// GBK "隆" is C2 A1, which UTF-8 accepts as "¡"; the marker keeps readback
// from sniffing these bytes as UTF-8 and parsing a corrupted path.
await withScheduledTaskScript(
{
scriptLines: ["@echo off", 'cd /d "C:\\Users\\隆\\.openclaw"', "node gateway.js"],
scriptEncoding: "gbk",
},
async (env) => {
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
workingDirectory: "C:\\Users\\隆\\.openclaw",
sourcePath: resolveTaskScriptPath(env),
});
},
);
});
it("returns null when script does not exist", async () => {
await withScheduledTaskScript({}, async (env) => {
const result = await readScheduledTaskCommand(env);
+19 -5
View File
@@ -15,6 +15,10 @@ import {
getWindowsPowerShellExePath,
getWindowsSystem32ExePath,
} from "../infra/windows-install-roots.js";
import {
decodeWindowsLauncherScript,
encodeWindowsLauncherScript,
} from "../infra/windows-launcher-encoding.js";
import { killProcessTree } from "../process/kill-tree.js";
import { sleep } from "../utils.js";
import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js";
@@ -249,7 +253,7 @@ export async function readScheduledTaskCommand(
): Promise<GatewayServiceCommandConfig | null> {
const scriptPath = resolveTaskScriptPath(env);
try {
const content = await fs.readFile(scriptPath, "utf8");
const content = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
let workingDirectory = "";
let commandLine = "";
const environment: Record<string, string> = {};
@@ -1304,13 +1308,16 @@ async function writeScheduledTaskScript({
workingDirectory,
environment: scriptEnvironment,
});
await fs.writeFile(scriptPath, script, "utf8");
await fs.writeFile(scriptPath, encodeWindowsLauncherScript({ format: "cmd", content: script }));
if (taskLaunchPath !== scriptPath) {
const launcher = buildHiddenLauncherScript({
description: taskDescription,
scriptPath,
});
await fs.writeFile(taskLaunchPath, launcher, "utf8");
await fs.writeFile(
taskLaunchPath,
encodeWindowsLauncherScript({ format: "vbs", content: launcher }),
);
}
return { scriptPath, taskLaunchPath, taskDescription, taskEnv };
}
@@ -1586,7 +1593,8 @@ async function activateScheduledTask(params: {
if (shouldFallbackToStartupEntry({ code: create.code, detail })) {
const startupEntryPath = resolveStartupEntryPath(params.env);
await fs.mkdir(path.dirname(startupEntryPath), { recursive: true });
const launcher = shouldUseHiddenWindowsTaskLauncher(params.env)
const useHiddenLauncher = shouldUseHiddenWindowsTaskLauncher(params.env);
const launcher = useHiddenLauncher
? buildHiddenLauncherScript({
description: taskDescription,
scriptPath: params.scriptPath,
@@ -1595,7 +1603,13 @@ async function activateScheduledTask(params: {
description: taskDescription,
scriptPath: params.scriptPath,
});
await fs.writeFile(startupEntryPath, launcher, "utf8");
await fs.writeFile(
startupEntryPath,
encodeWindowsLauncherScript({
format: useHiddenLauncher ? "vbs" : "cmd",
content: launcher,
}),
);
await launchFallbackTaskScript(params.env);
writeFormattedLines(
params.stdout,
@@ -22,3 +22,12 @@ vi.mock("../../infra/ports.js", () => ({
vi.mock("../../process/kill-tree.js", () => ({
killProcessTree: (pid: number, opts?: { graceMs?: number }) => killProcessTree(pid, opts),
}));
// Launcher encode/decode must not depend on the dev or CI machine's code page;
// unpinned, a CJK-locale Windows host would ANSI-encode fixture launcher files.
vi.mock("../../infra/windows-encoding.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/windows-encoding.js")>(
"../../infra/windows-encoding.js",
);
return { ...actual, resolveWindowsSystemEncoding: () => null };
});
+1 -1
View File
@@ -66,7 +66,7 @@ export function resolveWindowsConsoleEncoding(): string | null {
}
/** Resolves and caches the Windows system encoding used by legacy text files. */
function resolveWindowsSystemEncoding(): string | null {
export function resolveWindowsSystemEncoding(): string | null {
if (process.platform !== "win32") {
return null;
}
+187
View File
@@ -0,0 +1,187 @@
// Covers Windows launcher script encoding for wscript/cmd code page contracts (#107416).
import iconv from "iconv-lite";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
decodeWindowsLauncherScript,
encodeWindowsLauncherScript,
} from "./windows-launcher-encoding.js";
const resolveWindowsSystemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
vi.mock("./windows-encoding.js", async () => {
const actual =
await vi.importActual<typeof import("./windows-encoding.js")>("./windows-encoding.js");
return {
...actual,
resolveWindowsSystemEncoding: () => resolveWindowsSystemEncodingMock(),
};
});
const CJK_SCRIPT_PATH = "C:\\Users\\苗振\\.openclaw\\gateway.cmd";
const REPLACEMENT_CHAR = String.fromCharCode(0xfffd);
const GBK_MARKER = "@rem openclaw-launcher-encoding=gbk\r\n";
const EUC_KR_MARKER = "@rem openclaw-launcher-encoding=euc-kr\r\n";
beforeEach(() => {
resolveWindowsSystemEncodingMock.mockReset();
resolveWindowsSystemEncodingMock.mockReturnValue(null);
});
describe("encodeWindowsLauncherScript", () => {
it("writes vbs scripts as UTF-16 LE with BOM including CJK paths", () => {
const content = `CreateObject("WScript.Shell").Run """${CJK_SCRIPT_PATH}""", 0, False\r\n`;
const encoded = encodeWindowsLauncherScript({ format: "vbs", content });
expect(encoded.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
expect(encoded.subarray(2).toString("utf16le")).toBe(content);
});
it("writes vbs scripts as UTF-16 LE even for pure-ASCII content", () => {
const content = 'CreateObject("WScript.Shell").Run """C:\\gw.cmd""", 0, False\r\n';
const encoded = encodeWindowsLauncherScript({ format: "vbs", content });
expect(encoded.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
expect(encoded.subarray(2).toString("utf16le")).toBe(content);
});
it("keeps ASCII cmd scripts byte-identical to UTF-8 regardless of code page", () => {
const content = '@echo off\r\ncd /d "C:\\temp"\r\nnode gateway.js\r\n';
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: "gbk",
});
expect(encoded.equals(Buffer.from(content, "utf8"))).toBe(true);
});
it("encodes non-ASCII cmd scripts with a marker line plus CJK code page bytes", () => {
const content = `@echo off\r\ncd /d "C:\\Users\\苗振\\.openclaw"\r\nnode gateway.js\r\n`;
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: "gbk",
});
expect(encoded.equals(Buffer.from(content, "utf8"))).toBe(false);
expect(encoded.equals(iconv.encode(GBK_MARKER + content, "gbk"))).toBe(true);
expect(decodeWindowsLauncherScript({ buffer: encoded })).toBe(content);
});
it("round-trips cmd content whose GBK bytes are also valid UTF-8 (隆 -> C2 A1 -> ¡)", () => {
// Verify the trap on this iconv build: valid UTF-8, wrong string, no U+FFFD.
const collisionBytes = iconv.encode("隆", "gbk");
expect(collisionBytes.toString("utf8")).not.toBe("隆");
expect(collisionBytes.toString("utf8")).not.toContain(REPLACEMENT_CHAR);
const content = `@echo off\r\ncd /d "C:\\Users\\\\.openclaw"\r\nnode gateway.js\r\n`;
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: "gbk",
});
expect(encoded.equals(iconv.encode(GBK_MARKER + content, "gbk"))).toBe(true);
// Locks the regression: a raw UTF-8 readback of these bytes decodes
// cleanly (no replacement char) yet corrupts the path — the pre-marker bug.
expect(encoded.toString("utf8")).not.toContain(REPLACEMENT_CHAR);
expect(encoded.toString("utf8")).not.toContain("隆");
expect(decodeWindowsLauncherScript({ buffer: encoded })).toBe(content);
});
it("encodes cp949 extension syllables that Node ICU's euc-kr decoder rejects", () => {
// Windows code page 949 is cp949/UHC; "똠" (8C 63) is a UHC extension syllable
// iconv encodes and round-trips, but new TextDecoder("euc-kr") cannot decode
// (KS X 1001 only). The guard must verify euc-kr with iconv, not ICU.
const extensionBytes = iconv.encode("똠", "euc-kr");
expect(iconv.decode(extensionBytes, "euc-kr")).toBe("똠");
expect(new TextDecoder("euc-kr").decode(extensionBytes)).not.toBe("똠");
const content = `@echo off\r\ncd /d "C:\\Users\\똠이\\.openclaw"\r\nnode gateway.js\r\n`;
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: "euc-kr",
});
expect(encoded.equals(iconv.encode(EUC_KR_MARKER + content, "euc-kr"))).toBe(true);
expect(decodeWindowsLauncherScript({ buffer: encoded })).toBe(content);
});
it("falls back to UTF-8 when no system code page is available", () => {
const content = `@echo off\r\ncd /d "C:\\Users\\苗振"\r\n`;
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: null,
});
expect(encoded.equals(Buffer.from(content, "utf8"))).toBe(true);
});
it("falls back to UTF-8 on windows-125x hosts whose console page differs from ANSI", () => {
const content = '@echo off\r\ncd /d "C:\\Users\\café"\r\n';
const encoded = encodeWindowsLauncherScript({
format: "cmd",
content,
windowsEncoding: "windows-1252",
});
expect(encoded.equals(Buffer.from(content, "utf8"))).toBe(true);
});
it("resolves the system code page when no override is given", () => {
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
const content = `@echo off\r\ncd /d "C:\\Users\\苗振"\r\n`;
const encoded = encodeWindowsLauncherScript({ format: "cmd", content });
expect(encoded.equals(iconv.encode(GBK_MARKER + content, "gbk"))).toBe(true);
});
it("fails the install instead of writing unrepresentable cmd content", () => {
const content = '@echo off\r\nset "OC_LABEL=🚀"\r\n';
expect(() =>
encodeWindowsLauncherScript({ format: "cmd", content, windowsEncoding: "gbk" }),
).toThrow(/cannot be represented in the Windows system code page \(gbk\)/);
});
});
describe("decodeWindowsLauncherScript", () => {
it("strips the UTF-16 LE BOM and decodes vbs scripts", () => {
const content = `CreateObject("WScript.Shell").Run """${CJK_SCRIPT_PATH}""", 0, False\r\n`;
const buffer = encodeWindowsLauncherScript({ format: "vbs", content });
expect(decodeWindowsLauncherScript({ buffer })).toBe(content);
});
it("decodes unmarked legacy UTF-8 scripts with CJK paths", () => {
const content = `@echo off\r\ncd /d "C:\\Users\\苗振\\.openclaw"\r\nnode gateway.js\r\n`;
const buffer = Buffer.from(content, "utf8");
expect(decodeWindowsLauncherScript({ buffer })).toBe(content);
});
it("decodes marked code-page scripts with the recorded encoding", () => {
const content = "@echo off\r\nrem 你好\r\n";
const buffer = iconv.encode(GBK_MARKER + content, "gbk");
expect(decodeWindowsLauncherScript({ buffer })).toBe(content);
});
it("falls back to UTF-8 for marker labels iconv cannot decode", () => {
const content = "@rem openclaw-launcher-encoding=bogus\r\nnode gateway.js\r\n";
const buffer = Buffer.from(content, "utf8");
expect(decodeWindowsLauncherScript({ buffer })).toBe(content);
});
it("degrades unmarked non-UTF-8 bytes to UTF-8 replacement output", () => {
// Unmarked code-page files were never produced by a shipped release (the
// code-page writer ships together with the marker), so deterministic UTF-8
// is the correct total behavior for everything without a marker.
const buffer = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]);
expect(decodeWindowsLauncherScript({ buffer })).toContain(REPLACEMENT_CHAR);
});
});
+112
View File
@@ -0,0 +1,112 @@
/** Encodes and decodes generated Windows launcher scripts (`.cmd` / `.vbs`). */
import iconv from "iconv-lite";
import { resolveWindowsSystemEncoding } from "./windows-encoding.js";
type WindowsLauncherScriptFormat = "cmd" | "vbs";
const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
// cmd.exe decodes batch files with the console OEM code page, which matches the
// ANSI code page only on these locales. windows-125x hosts pair ANSI with a
// separate OEM page (437/850/852/866/...) that WHATWG decoders cannot model, so
// non-ASCII cmd content stays UTF-8 there instead of guessing wrong bytes.
const CMD_ANSI_EQUALS_OEM_ENCODINGS = new Set([
"gbk",
"big5",
"shift_jis",
"euc-kr",
"gb18030",
"windows-874",
]);
// Code-page cmd launchers record their encoding in this ASCII comment line so
// readback never has to guess: some code-page byte sequences are also valid
// UTF-8 (GBK "隆" is C2 A1, which UTF-8 reads as "¡"), so content sniffing
// silently corrupts paths.
const LAUNCHER_ENCODING_MARKER_PREFIX = "@rem openclaw-launcher-encoding=";
const LAUNCHER_ENCODING_MARKER_RE = /^@rem openclaw-launcher-encoding=(\S+)\s*$/;
function isAsciiOnly(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
if (value.charCodeAt(index) > 0x7f) {
return false;
}
}
return true;
}
/**
* wscript.exe reads .vbs only as ANSI or UTF-16 LE with BOM, and cmd.exe reads
* .cmd in the console code page; plain UTF-8 garbles CJK profile paths into
* "file not found" launch failures (#107416). Do not simplify back to utf8.
*/
export function encodeWindowsLauncherScript(params: {
format: WindowsLauncherScriptFormat;
content: string;
windowsEncoding?: string | null;
}): Buffer {
if (params.format === "vbs") {
// UTF-16 LE with BOM is the one wscript encoding that works on every locale.
return Buffer.concat([UTF16LE_BOM, Buffer.from(params.content, "utf16le")]);
}
if (isAsciiOnly(params.content)) {
// ASCII bytes are identical in UTF-8 and every Windows code page; keep the
// legacy byte-for-byte output so non-CJK installs see no change.
return Buffer.from(params.content, "utf8");
}
const encoding =
params.windowsEncoding !== undefined ? params.windowsEncoding : resolveWindowsSystemEncoding();
if (
!encoding ||
!CMD_ANSI_EQUALS_OEM_ENCODINGS.has(encoding) ||
!iconv.encodingExists(encoding)
) {
return Buffer.from(params.content, "utf8");
}
// Generated launcher scripts are CRLF-terminated throughout; the marker is
// ASCII, so it encodes byte-identically in every safe code page.
const marked = `${LAUNCHER_ENCODING_MARKER_PREFIX}${encoding}\r\n${params.content}`;
const encoded = iconv.encode(marked, encoding);
// iconv-lite substitutes "?" for unmappable characters, which would silently
// corrupt paths; verify the round-trip and fail the install before any
// launcher file is written. Node ICU's euc-kr decoder is KS X 1001 only, but
// Windows code page 949 is cp949/UHC, so ICU false-rejects the ~8,800 UHC
// extension syllables cmd.exe reads fine — verify euc-kr with iconv's own
// cp949 decode instead. The other five labels match Windows in ICU, which
// also flags best-fit hazards (shift_jis ¥ -> 0x5C) iconv's decode would miss.
const decoded =
encoding === "euc-kr"
? iconv.decode(encoded, encoding)
: new TextDecoder(encoding).decode(encoded);
if (decoded !== marked) {
throw new Error(
`Windows ${params.format} launcher script contains characters that cannot be represented in the Windows system code page (${encoding}); cmd.exe would misread the script. Remove those characters or switch Windows to UTF-8 (code page 65001).`,
);
}
return encoded;
}
/** Decodes launcher scripts written by any OpenClaw version (UTF-16 LE BOM, marked code page, or UTF-8). */
export function decodeWindowsLauncherScript(params: { buffer: Buffer }): string {
const { buffer } = params;
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return buffer.subarray(2).toString("utf16le");
}
// The marker line is pure ASCII and no CMD_ANSI_EQUALS_OEM_ENCODINGS multibyte
// sequence contains 0x0A, so the first newline in a marked file is exactly
// the marker terminator. latin1 (not "ascii", which masks the high bit and
// could alias garbage bytes into the prefix) keeps the byte-level read exact.
const newlineIndex = buffer.indexOf(0x0a);
if (newlineIndex !== -1) {
const marker = LAUNCHER_ENCODING_MARKER_RE.exec(
buffer.subarray(0, newlineIndex).toString("latin1"),
);
// encodingExists guards hand-edited marker labels so a bad label degrades
// to the UTF-8 fallback instead of iconv.decode throwing mid-poll.
if (marker?.[1] && iconv.encodingExists(marker[1])) {
return iconv.decode(buffer.subarray(newlineIndex + 1), marker[1]);
}
}
// No marker: ASCII scripts and pre-marker legacy UTF-8 installs.
return buffer.toString("utf8");
}
+69
View File
@@ -6,6 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureFullEnv } from "../test-utils/env.js";
import { getWindowsCmdExePath } from "./windows-install-roots.js";
import { decodeWindowsLauncherScript } from "./windows-launcher-encoding.js";
const spawnMock = vi.hoisted(() => vi.fn());
const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir()));
@@ -15,6 +16,9 @@ const resolveTaskScriptPathMock = vi.hoisted(() =>
return path.join(home, ".openclaw", "gateway.cmd");
}),
);
// Pin code page detection so hosts with CJK home paths cannot leak the real
// PowerShell probe into script-encoding assertions.
const resolveWindowsSystemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
@@ -32,6 +36,14 @@ vi.mock("../daemon/schtasks.js", () => ({
resolveTaskScriptPath: (env: Record<string, string | undefined>) =>
resolveTaskScriptPathMock(env),
}));
vi.mock("./windows-encoding.js", async () => {
const actual =
await vi.importActual<typeof import("./windows-encoding.js")>("./windows-encoding.js");
return {
...actual,
resolveWindowsSystemEncoding: () => resolveWindowsSystemEncodingMock(),
};
});
type WindowsTaskRestartModule = typeof import("./windows-task-restart.js");
@@ -90,6 +102,8 @@ describe("relaunchGatewayScheduledTask", () => {
const home = env.USERPROFILE || env.HOME || os.homedir();
return path.join(home, ".openclaw", "gateway.cmd");
});
resolveWindowsSystemEncodingMock.mockReset();
resolveWindowsSystemEncodingMock.mockReturnValue(null);
});
it("writes a detached schtasks relaunch helper", () => {
@@ -124,6 +138,8 @@ describe("relaunchGatewayScheduledTask", () => {
}
expect(fs.statSync(scriptPath).isFile()).toBe(true);
const script = fs.readFileSync(scriptPath, "utf8");
// ASCII helper scripts stay marker-free UTF-8 bytes.
expect(script.startsWith("@echo off\r\n")).toBe(true);
expect(script).toContain("timeout /t 1 /nobreak >nul");
expect(script).toContain("gateway-restart.log");
expect(script).toContain(
@@ -248,4 +264,57 @@ describe("relaunchGatewayScheduledTask", () => {
expect(script).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
expect(script).toContain(taskScriptPath);
});
// Pin the host home/state paths embedded in the script to ASCII so the only
// code-page-sensitive content in the gbk tests is the task name under test;
// otherwise a non-GBK Windows username (Hangul/Thai/...) fails the encode.
const asciiPathEnv = {
HOME: "C:\\ocw-test",
USERPROFILE: "C:\\ocw-test",
OPENCLAW_STATE_DIR: "C:\\ocw-test\\state",
};
it("writes marked code-page bytes for CJK task names that decode back exactly", () => {
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
spawnMock.mockImplementation((_file: string, args: string[]) => {
createdScriptPaths.add(decodeCmdPathArg(expectDefined(args[3], "args[3] test invariant")));
return { unref: vi.fn() };
});
const result = relaunchGatewayScheduledTask({
...asciiPathEnv,
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (隆)",
});
expect(result.ok).toBe(true);
const scriptPath = expectDefined(
[...createdScriptPaths][0],
"[...createdScriptPaths][0] test invariant",
);
const raw = fs.readFileSync(scriptPath);
expect(raw.toString("latin1").startsWith("@rem openclaw-launcher-encoding=gbk\r\n")).toBe(true);
// The old raw-UTF-8 writer would have kept the task name readable here.
expect(raw.toString("utf8")).not.toContain("隆");
const script = decodeWindowsLauncherScript({ buffer: raw });
expect(script.startsWith("@echo off\r\n")).toBe(true);
expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (隆)" >>');
expect(script).toContain('del "%~f0" >nul 2>&1');
});
it("returns failed instead of writing an unrepresentable helper script", () => {
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
spawnMock.mockImplementation(() => {
throw new Error("spawn should not be reached");
});
const result = relaunchGatewayScheduledTask({
...asciiPathEnv,
OPENCLAW_WINDOWS_TASK_NAME: "🚀",
});
expect(result.ok).toBe(false);
expect(result.method).toBe("schtasks");
expect(result.detail).toMatch(/cannot be represented/);
expect(spawnMock).not.toHaveBeenCalled();
});
});
+12 -7
View File
@@ -11,6 +11,7 @@ import { formatErrorMessage } from "./errors.js";
import type { RestartAttempt } from "./restart.types.js";
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
import { getWindowsCmdExePath } from "./windows-install-roots.js";
import { encodeWindowsLauncherScript } from "./windows-launcher-encoding.js";
const TASK_RESTART_RETRY_LIMIT = 12;
const TASK_RESTART_RETRY_DELAY_SEC = 1;
@@ -89,15 +90,19 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
const quotedScriptPath = quoteCmdScriptArg(scriptPath);
const restartLog = renderCmdRestartLogSetup({ ...process.env, ...env });
try {
// The script embeds host paths and the task name; cmd.exe decodes it with
// the console code page, so plain UTF-8 garbles CJK content (#107416).
fs.writeFileSync(
scriptPath,
`${buildScheduledTaskRestartScript({
quotedLogPath: restartLog.quotedLogPath,
setupLines: restartLog.lines,
taskName,
taskScriptPath,
})}\r\n`,
"utf8",
encodeWindowsLauncherScript({
format: "cmd",
content: `${buildScheduledTaskRestartScript({
quotedLogPath: restartLog.quotedLogPath,
setupLines: restartLog.lines,
taskName,
taskScriptPath,
})}\r\n`,
}),
);
const cmdExePath = getWindowsCmdExePath();
const child = spawn(cmdExePath, ["/d", "/s", "/c", quotedScriptPath], {