mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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], {
|
||||
|
||||
Reference in New Issue
Block a user