Skip to content

Commit ccec022

Browse files
wsyjh8steipete
andauthored
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 <[email protected]> * style(windows): format launcher encoding test --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent b23292c commit ccec022

13 files changed

Lines changed: 563 additions & 27 deletions

npm-shrinkwrap.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,6 +2034,7 @@
20342034
"grammy": "1.44.0",
20352035
"highlight.js": "11.11.1",
20362036
"hosted-git-info": "10.1.1",
2037+
"iconv-lite": "0.7.2",
20372038
"ignore": "7.0.5",
20382039
"jiti": "2.7.0",
20392040
"json5": "2.2.3",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/daemon/schtasks.install.test.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { PassThrough } from "node:stream";
66
import { beforeEach, describe, expect, it, vi } from "vitest";
7+
import { decodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
78
import {
89
installScheduledTask,
910
readScheduledTaskCommand,
@@ -13,6 +14,19 @@ import {
1314
import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js";
1415
import { buildServiceEnvironment } from "./service-env.js";
1516

17+
const resolveWindowsSystemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
18+
19+
// Pin code page detection so launcher encoding never depends on the host ACP.
20+
vi.mock("../infra/windows-encoding.js", async () => {
21+
const actual = await vi.importActual<typeof import("../infra/windows-encoding.js")>(
22+
"../infra/windows-encoding.js",
23+
);
24+
return {
25+
...actual,
26+
resolveWindowsSystemEncoding: () => resolveWindowsSystemEncodingMock(),
27+
};
28+
});
29+
1630
const schtasksCalls: string[][] = [];
1731
const schtasksResponses: { code: number; stdout: string; stderr: string }[] = [];
1832
// Captures the XML payload at /Create /XML time before the production code's
@@ -45,6 +59,8 @@ beforeEach(() => {
4559
schtasksCalls.length = 0;
4660
schtasksResponses.length = 0;
4761
xmlPayloadCaptures.length = 0;
62+
resolveWindowsSystemEncodingMock.mockReset();
63+
resolveWindowsSystemEncodingMock.mockReturnValue(null);
4864
});
4965

5066
describe("installScheduledTask", () => {
@@ -115,7 +131,7 @@ describe("installScheduledTask", () => {
115131
},
116132
});
117133

118-
const script = await fs.readFile(scriptPath, "utf8");
134+
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
119135
expect(script).toContain('cd /d "C:\\temp\\poc&calc"');
120136
expect(script).toContain(
121137
'node gateway.js --display-name "safe&whoami" --percent "%%TEMP%%" --bang "^!token^!"',
@@ -228,9 +244,12 @@ describe("installScheduledTask", () => {
228244
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
229245
});
230246
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
231-
const launcher = await fs.readFile(launcherPath, "utf8");
247+
const rawLauncher = await fs.readFile(launcherPath);
248+
const launcher = decodeWindowsLauncherScript({ buffer: rawLauncher });
232249

233250
expectInitialTaskQueries();
251+
// wscript only accepts UTF-16 LE with BOM or ANSI; UTF-16 keeps CJK paths intact.
252+
expect(rawLauncher.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
234253
// `/Create /XML` argv shape: ["/Create", "/F", "/TN", "<name>", "/XML", "<path>", "/RU", "<user>", "/NP"].
235254
// The XML payload is what carries the SC, RL, TR, and battery settings now.
236255
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
@@ -248,6 +267,45 @@ describe("installScheduledTask", () => {
248267
});
249268
});
250269

270+
it("writes hidden launchers wscript can decode for CJK profile paths (#107416)", async () => {
271+
await withUserProfileDir(async (tmpDir, _env) => {
272+
const cjkProfileDir = path.join(tmpDir, "苗振");
273+
await fs.mkdir(cjkProfileDir, { recursive: true });
274+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
275+
276+
const { scriptPath } = await installDefaultGatewayTask({
277+
USERPROFILE: cjkProfileDir,
278+
OPENCLAW_PROFILE: "default",
279+
OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1",
280+
});
281+
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
282+
const rawLauncher = await fs.readFile(launcherPath);
283+
284+
expect(scriptPath).toContain("苗振");
285+
expect(rawLauncher.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
286+
expect(rawLauncher.subarray(2).toString("utf16le")).toContain(
287+
`Run """${scriptPath}""", 0, False`,
288+
);
289+
});
290+
});
291+
292+
it("fails the install instead of writing an unrepresentable cmd launcher", async () => {
293+
await withUserProfileDir(async (_tmpDir, env) => {
294+
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
295+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
296+
297+
await expect(
298+
installScheduledTask({
299+
env,
300+
stdout: new PassThrough(),
301+
programArguments: ["node", "gateway.js"],
302+
environment: { OC_LABEL: "🚀" },
303+
}),
304+
).rejects.toThrow(/cannot be represented in the Windows system code page/);
305+
await expect(fs.access(resolveTaskScriptPath(env))).rejects.toThrow();
306+
});
307+
});
308+
251309
it("uses the hidden launcher for generated Windows gateway service installs", async () => {
252310
await withUserProfileDir(async (_tmpDir, env) => {
253311
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
@@ -279,8 +337,8 @@ describe("installScheduledTask", () => {
279337
},
280338
});
281339
const launcherPath = scriptPath.replace(/\.cmd$/i, ".vbs");
282-
const script = await fs.readFile(scriptPath, "utf8");
283-
const launcher = await fs.readFile(launcherPath, "utf8");
340+
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
341+
const launcher = decodeWindowsLauncherScript({ buffer: await fs.readFile(launcherPath) });
284342

285343
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
286344
"/Create",
@@ -515,7 +573,7 @@ describe("installScheduledTask", () => {
515573
},
516574
});
517575

518-
const script = await fs.readFile(scriptPath, "utf8");
576+
const script = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
519577
expect(script).not.toContain('set "PATH=');
520578
expect(script).toContain('set "OPENCLAW_GATEWAY_PORT=18789"');
521579
});

src/daemon/schtasks.startup-fallback.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getWindowsCmdExePath,
99
getWindowsPowerShellExePath,
1010
} from "../infra/windows-install-roots.js";
11+
import { decodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
1112
import "./test-helpers/schtasks-base-mocks.js";
1213
import type { GatewayServiceRuntime } from "./service-runtime.js";
1314
import {
@@ -375,7 +376,9 @@ describe("Windows startup fallback", () => {
375376
const result = await installGatewayScheduledTask(env, stdout);
376377

377378
const startupEntryPath = resolveStartupEntryPath(env);
378-
const startupScript = await fs.readFile(startupEntryPath, "utf8");
379+
const startupScript = decodeWindowsLauncherScript({
380+
buffer: await fs.readFile(startupEntryPath),
381+
});
379382
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
380383
expect(startupScript).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
381384
expect(startupScript).toContain("gateway.cmd");
@@ -397,8 +400,11 @@ describe("Windows startup fallback", () => {
397400
});
398401

399402
const startupEntryPath = resolveStartupEntryPath(env, "vbs");
400-
const startupScript = await fs.readFile(startupEntryPath, "utf8");
403+
const rawStartupScript = await fs.readFile(startupEntryPath);
404+
const startupScript = decodeWindowsLauncherScript({ buffer: rawStartupScript });
401405
expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
406+
// wscript only accepts UTF-16 LE with BOM or ANSI; UTF-16 keeps CJK paths intact.
407+
expect(rawStartupScript.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe]));
402408
expect(startupScript).toContain("WScript.Shell");
403409
expect(startupScript).toContain("gateway.cmd");
404410
expect(startupScript).toContain(`Run """${result.scriptPath}""", 0, False`);
@@ -860,7 +866,7 @@ describe("Windows startup fallback", () => {
860866
const startupEntryPath = await writeStartupFallbackEntry(env);
861867
await writeGatewayScript(env, 18789);
862868
const scriptPath = resolveTaskScriptPath(env);
863-
const scriptBefore = await fs.readFile(scriptPath, "utf8");
869+
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
864870
env.OPENCLAW_GATEWAY_PORT = "19433";
865871
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
866872
spawnSync.mockImplementation((command, args) => {
@@ -923,7 +929,9 @@ describe("Windows startup fallback", () => {
923929
);
924930
expect(oldPidKills).toHaveLength(0);
925931
expect(schtasksResponses).toHaveLength(pendingSchtasksResponses);
926-
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
932+
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
933+
scriptBefore,
934+
);
927935
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
928936
});
929937
});
@@ -933,7 +941,7 @@ describe("Windows startup fallback", () => {
933941
const startupEntryPath = await writeStartupFallbackEntry(env);
934942
await writeGatewayScript(env, 18789);
935943
const scriptPath = resolveTaskScriptPath(env);
936-
const scriptBefore = await fs.readFile(scriptPath, "utf8");
944+
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
937945
env.OPENCLAW_GATEWAY_PORT = "19433";
938946
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
939947
spawnSync.mockImplementation((command, args) =>
@@ -974,7 +982,9 @@ describe("Windows startup fallback", () => {
974982
"replacement gateway port 19433 is occupied by an unverified process",
975983
);
976984

977-
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
985+
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
986+
scriptBefore,
987+
);
978988
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
979989
});
980990
});
@@ -984,7 +994,7 @@ describe("Windows startup fallback", () => {
984994
const startupEntryPath = await writeStartupFallbackEntry(env);
985995
await writeGatewayScript(env, 18789);
986996
const scriptPath = resolveTaskScriptPath(env);
987-
const scriptBefore = await fs.readFile(scriptPath, "utf8");
997+
const scriptBefore = decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) });
988998
env.OPENCLAW_GATEWAY_PORT = "19433";
989999
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
9901000
spawnSync.mockImplementation((command, args) =>
@@ -1014,7 +1024,9 @@ describe("Windows startup fallback", () => {
10141024
"Could not verify replacement gateway port 19433",
10151025
);
10161026

1017-
await expect(fs.readFile(scriptPath, "utf8")).resolves.toBe(scriptBefore);
1027+
expect(decodeWindowsLauncherScript({ buffer: await fs.readFile(scriptPath) })).toBe(
1028+
scriptBefore,
1029+
);
10181030
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
10191031
});
10201032
});

src/daemon/schtasks.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { encodeWindowsLauncherScript } from "../infra/windows-launcher-encoding.js";
67
import {
78
readScheduledTaskCommand,
89
readScheduledTaskRuntime,
@@ -198,6 +199,7 @@ describe("readScheduledTaskCommand", () => {
198199
async function withScheduledTaskScript(
199200
options: {
200201
scriptLines?: string[];
202+
scriptEncoding?: "utf8" | "gbk";
201203
env?:
202204
| Record<string, string | undefined>
203205
| ((tmpDir: string) => Record<string, string | undefined>);
@@ -214,8 +216,19 @@ describe("readScheduledTaskCommand", () => {
214216
};
215217
if (options.scriptLines) {
216218
const scriptPath = resolveTaskScriptPath(env);
219+
const script = options.scriptLines.join("\r\n");
217220
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
218-
await fs.writeFile(scriptPath, options.scriptLines.join("\r\n"), "utf8");
221+
await fs.writeFile(
222+
scriptPath,
223+
options.scriptEncoding === "gbk"
224+
? // Production bytes for a code-page install: marker line + GBK body.
225+
encodeWindowsLauncherScript({
226+
format: "cmd",
227+
content: script,
228+
windowsEncoding: "gbk",
229+
})
230+
: Buffer.from(script, "utf8"),
231+
);
219232
}
220233
await run(env);
221234
} finally {
@@ -239,6 +252,58 @@ describe("readScheduledTaskCommand", () => {
239252
);
240253
});
241254

255+
it("reads legacy UTF-8 scripts with CJK paths written before the encoding fix", async () => {
256+
await withScheduledTaskScript(
257+
{
258+
scriptLines: ["@echo off", 'cd /d "C:\\Users\\苗振\\.openclaw"', "node gateway.js"],
259+
},
260+
async (env) => {
261+
const result = await readScheduledTaskCommand(env);
262+
expect(result).toEqual({
263+
programArguments: ["node", "gateway.js"],
264+
workingDirectory: "C:\\Users\\苗振\\.openclaw",
265+
sourcePath: resolveTaskScriptPath(env),
266+
});
267+
},
268+
);
269+
});
270+
271+
it("reads marked ANSI scripts with CJK paths under a CJK code page (#107416)", async () => {
272+
await withScheduledTaskScript(
273+
{
274+
scriptLines: ["@echo off", 'cd /d "C:\\Users\\苗振\\.openclaw"', "node gateway.js"],
275+
scriptEncoding: "gbk",
276+
},
277+
async (env) => {
278+
const result = await readScheduledTaskCommand(env);
279+
expect(result).toEqual({
280+
programArguments: ["node", "gateway.js"],
281+
workingDirectory: "C:\\Users\\苗振\\.openclaw",
282+
sourcePath: resolveTaskScriptPath(env),
283+
});
284+
},
285+
);
286+
});
287+
288+
it("reads back GBK launchers whose bytes are also valid UTF-8 (隆) without corruption", async () => {
289+
// GBK "隆" is C2 A1, which UTF-8 accepts as "¡"; the marker keeps readback
290+
// from sniffing these bytes as UTF-8 and parsing a corrupted path.
291+
await withScheduledTaskScript(
292+
{
293+
scriptLines: ["@echo off", 'cd /d "C:\\Users\\隆\\.openclaw"', "node gateway.js"],
294+
scriptEncoding: "gbk",
295+
},
296+
async (env) => {
297+
const result = await readScheduledTaskCommand(env);
298+
expect(result).toEqual({
299+
programArguments: ["node", "gateway.js"],
300+
workingDirectory: "C:\\Users\\隆\\.openclaw",
301+
sourcePath: resolveTaskScriptPath(env),
302+
});
303+
},
304+
);
305+
});
306+
242307
it("returns null when script does not exist", async () => {
243308
await withScheduledTaskScript({}, async (env) => {
244309
const result = await readScheduledTaskCommand(env);

0 commit comments

Comments
 (0)