diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9942195919ca..3d63a62635cc 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -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", diff --git a/package.json b/package.json index 2aa51b921257..0300b534ae8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0993dd8fbd91..58fae45aa7bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/daemon/schtasks.install.test.ts b/src/daemon/schtasks.install.test.ts index 3bfdedb5b6f1..7bb4b82717d9 100644 --- a/src/daemon/schtasks.install.test.ts +++ b/src/daemon/schtasks.install.test.ts @@ -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( + "../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", "", "/XML", "", "/RU", "", "/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"'); }); diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index b6afd8aad7fb..bcd6483cdf0b 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -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(); }); }); diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index dc835ab853e6..a956aa3b8afd 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -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 | ((tmpDir: string) => Record); @@ -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); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 26119d5792c7..110f7d22bdcb 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -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 { 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 = {}; @@ -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, diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index 4e2a511dabcc..1e4b19ef1fb4 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -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( + "../../infra/windows-encoding.js", + ); + return { ...actual, resolveWindowsSystemEncoding: () => null }; +}); diff --git a/src/infra/windows-encoding.ts b/src/infra/windows-encoding.ts index 834f935cf892..c1a0af80068a 100644 --- a/src/infra/windows-encoding.ts +++ b/src/infra/windows-encoding.ts @@ -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; } diff --git a/src/infra/windows-launcher-encoding.test.ts b/src/infra/windows-launcher-encoding.test.ts new file mode 100644 index 000000000000..1d797a80107e --- /dev/null +++ b/src/infra/windows-launcher-encoding.test.ts @@ -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("./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); + }); +}); diff --git a/src/infra/windows-launcher-encoding.ts b/src/infra/windows-launcher-encoding.ts new file mode 100644 index 000000000000..dfba9e692f59 --- /dev/null +++ b/src/infra/windows-launcher-encoding.ts @@ -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"); +} diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index 8aa639f9fee0..25088a9bb4d3 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -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) => resolveTaskScriptPathMock(env), })); +vi.mock("./windows-encoding.js", async () => { + const actual = + await vi.importActual("./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(); + }); }); diff --git a/src/infra/windows-task-restart.ts b/src/infra/windows-task-restart.ts index da38b37722e8..ca0a7f34afa9 100644 --- a/src/infra/windows-task-restart.ts +++ b/src/infra/windows-task-restart.ts @@ -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], {