Skip to content

Commit a562060

Browse files
committed
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.
1 parent b311a3c commit a562060

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

src/infra/windows-task-restart.test.ts

Lines changed: 69 additions & 0 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 { expectDefined } from "@openclaw/normalization-core";
66
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { decodeWindowsLauncherScript } from "../daemon/launcher-encoding.js";
78
import { captureFullEnv } from "../test-utils/env.js";
89
import { getWindowsCmdExePath } from "./windows-install-roots.js";
910

@@ -15,6 +16,9 @@ const resolveTaskScriptPathMock = vi.hoisted(() =>
1516
return path.join(home, ".openclaw", "gateway.cmd");
1617
}),
1718
);
19+
// Pin code page detection so hosts with CJK home paths cannot leak the real
20+
// PowerShell probe into script-encoding assertions.
21+
const resolveWindowsSystemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
1822

1923
vi.mock("node:child_process", async () => {
2024
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
@@ -32,6 +36,14 @@ vi.mock("../daemon/schtasks.js", () => ({
3236
resolveTaskScriptPath: (env: Record<string, string | undefined>) =>
3337
resolveTaskScriptPathMock(env),
3438
}));
39+
vi.mock("./windows-encoding.js", async () => {
40+
const actual =
41+
await vi.importActual<typeof import("./windows-encoding.js")>("./windows-encoding.js");
42+
return {
43+
...actual,
44+
resolveWindowsSystemEncoding: () => resolveWindowsSystemEncodingMock(),
45+
};
46+
});
3547

3648
type WindowsTaskRestartModule = typeof import("./windows-task-restart.js");
3749

@@ -90,6 +102,8 @@ describe("relaunchGatewayScheduledTask", () => {
90102
const home = env.USERPROFILE || env.HOME || os.homedir();
91103
return path.join(home, ".openclaw", "gateway.cmd");
92104
});
105+
resolveWindowsSystemEncodingMock.mockReset();
106+
resolveWindowsSystemEncodingMock.mockReturnValue(null);
93107
});
94108

95109
it("writes a detached schtasks relaunch helper", () => {
@@ -124,6 +138,8 @@ describe("relaunchGatewayScheduledTask", () => {
124138
}
125139
expect(fs.statSync(scriptPath).isFile()).toBe(true);
126140
const script = fs.readFileSync(scriptPath, "utf8");
141+
// ASCII helper scripts stay marker-free UTF-8 bytes.
142+
expect(script.startsWith("@echo off\r\n")).toBe(true);
127143
expect(script).toContain("timeout /t 1 /nobreak >nul");
128144
expect(script).toContain("gateway-restart.log");
129145
expect(script).toContain(
@@ -248,4 +264,57 @@ describe("relaunchGatewayScheduledTask", () => {
248264
expect(script).toContain(`start "" /min ${getWindowsCmdExePath()} /d /c`);
249265
expect(script).toContain(taskScriptPath);
250266
});
267+
268+
// Pin the host home/state paths embedded in the script to ASCII so the only
269+
// code-page-sensitive content in the gbk tests is the task name under test;
270+
// otherwise a non-GBK Windows username (Hangul/Thai/...) fails the encode.
271+
const asciiPathEnv = {
272+
HOME: "C:\\ocw-test",
273+
USERPROFILE: "C:\\ocw-test",
274+
OPENCLAW_STATE_DIR: "C:\\ocw-test\\state",
275+
};
276+
277+
it("writes marked code-page bytes for CJK task names that decode back exactly", () => {
278+
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
279+
spawnMock.mockImplementation((_file: string, args: string[]) => {
280+
createdScriptPaths.add(decodeCmdPathArg(expectDefined(args[3], "args[3] test invariant")));
281+
return { unref: vi.fn() };
282+
});
283+
284+
const result = relaunchGatewayScheduledTask({
285+
...asciiPathEnv,
286+
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (隆)",
287+
});
288+
289+
expect(result.ok).toBe(true);
290+
const scriptPath = expectDefined(
291+
[...createdScriptPaths][0],
292+
"[...createdScriptPaths][0] test invariant",
293+
);
294+
const raw = fs.readFileSync(scriptPath);
295+
expect(raw.toString("latin1").startsWith("@rem openclaw-launcher-encoding=gbk\r\n")).toBe(true);
296+
// The old raw-UTF-8 writer would have kept the task name readable here.
297+
expect(raw.toString("utf8")).not.toContain("隆");
298+
const script = decodeWindowsLauncherScript({ buffer: raw });
299+
expect(script.startsWith("@echo off\r\n")).toBe(true);
300+
expect(script).toContain('schtasks /Run /TN "OpenClaw Gateway (隆)" >>');
301+
expect(script).toContain('del "%~f0" >nul 2>&1');
302+
});
303+
304+
it("returns failed instead of writing an unrepresentable helper script", () => {
305+
resolveWindowsSystemEncodingMock.mockReturnValue("gbk");
306+
spawnMock.mockImplementation(() => {
307+
throw new Error("spawn should not be reached");
308+
});
309+
310+
const result = relaunchGatewayScheduledTask({
311+
...asciiPathEnv,
312+
OPENCLAW_WINDOWS_TASK_NAME: "🚀",
313+
});
314+
315+
expect(result.ok).toBe(false);
316+
expect(result.method).toBe("schtasks");
317+
expect(result.detail).toMatch(/cannot be represented/);
318+
expect(spawnMock).not.toHaveBeenCalled();
319+
});
251320
});

src/infra/windows-task-restart.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import fs from "node:fs";
55
import path from "node:path";
66
import { quoteCmdScriptArg } from "../daemon/cmd-argv.js";
77
import { resolveGatewayWindowsTaskName } from "../daemon/constants.js";
8+
import { encodeWindowsLauncherScript } from "../daemon/launcher-encoding.js";
89
import { renderCmdRestartLogSetup } from "../daemon/restart-logs.js";
910
import { resolveTaskScriptPath } from "../daemon/schtasks.js";
1011
import { formatErrorMessage } from "./errors.js";
@@ -89,15 +90,19 @@ export function relaunchGatewayScheduledTask(env: NodeJS.ProcessEnv = process.en
8990
const quotedScriptPath = quoteCmdScriptArg(scriptPath);
9091
const restartLog = renderCmdRestartLogSetup({ ...process.env, ...env });
9192
try {
93+
// The script embeds host paths and the task name; cmd.exe decodes it with
94+
// the console code page, so plain UTF-8 garbles CJK content (#107416).
9295
fs.writeFileSync(
9396
scriptPath,
94-
`${buildScheduledTaskRestartScript({
95-
quotedLogPath: restartLog.quotedLogPath,
96-
setupLines: restartLog.lines,
97-
taskName,
98-
taskScriptPath,
99-
})}\r\n`,
100-
"utf8",
97+
encodeWindowsLauncherScript({
98+
format: "cmd",
99+
content: `${buildScheduledTaskRestartScript({
100+
quotedLogPath: restartLog.quotedLogPath,
101+
setupLines: restartLog.lines,
102+
taskName,
103+
taskScriptPath,
104+
})}\r\n`,
105+
}),
101106
);
102107
const cmdExePath = getWindowsCmdExePath();
103108
const child = spawn(cmdExePath, ["/d", "/s", "/c", quotedScriptPath], {

0 commit comments

Comments
 (0)