mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
e228a3ca3a
* fix(daemon): encode Windows cmd launcher to the console OEM code page * fix(infra): unexport resolveWindowsSystemEncoding The launcher encoder now resolves the boot-time OEM code page via resolveWindowsOemEncoding, leaving resolveWindowsSystemEncoding with no external importers; the dead-export ratchet (knip all-exports) fails CI on exported-but-module-local functions. * test(windows): cover OEM launcher encoding resolver * fix(windows): declare launcher OEM code page * fix(windows): fail closed on unsupported OEM pages * fix(windows): reject CP1258 precomposition drift * fix(windows): guard CP864 launcher syntax * test(windows): pin OEM launcher code page --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
// Windows schtasks tests cover scheduled task service lifecycle behavior.
|
|
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,
|
|
resolveTaskScriptPath,
|
|
} from "./schtasks.js";
|
|
|
|
const schtasksResponses = vi.hoisted(
|
|
(): Array<{ code: number; stdout: string; stderr: string }> => [],
|
|
);
|
|
const resolveWindowsOemEncodingMock = vi.hoisted(() => vi.fn((): string | null => null));
|
|
|
|
vi.mock("./schtasks-exec.js", () => ({
|
|
execSchtasks: async () => schtasksResponses.shift() ?? { code: 0, stdout: "", stderr: "" },
|
|
}));
|
|
|
|
vi.mock("../infra/windows-encoding.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../infra/windows-encoding.js")>(
|
|
"../infra/windows-encoding.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveWindowsOemCodePage: () => 437,
|
|
resolveWindowsOemEncoding: () => resolveWindowsOemEncodingMock(),
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
schtasksResponses.length = 0;
|
|
resolveWindowsOemEncodingMock.mockReset();
|
|
resolveWindowsOemEncodingMock.mockReturnValue(null);
|
|
});
|
|
|
|
describe("scheduled task runtime derivation", () => {
|
|
async function readRuntimeFromQueryOutput(output: string) {
|
|
schtasksResponses.push(
|
|
{ code: 0, stdout: "", stderr: "" },
|
|
{ code: 0, stdout: output, stderr: "" },
|
|
);
|
|
return await readScheduledTaskRuntime({
|
|
USERPROFILE: "C:\\Users\\test",
|
|
OPENCLAW_PROFILE: "default",
|
|
});
|
|
}
|
|
|
|
function taskQueryOutput(lines: string[]): string {
|
|
return [
|
|
"TaskName: \\OpenClaw Gateway",
|
|
"Last Run Time: 1/8/2026 1:23:45 AM",
|
|
...lines,
|
|
"",
|
|
].join("\r\n");
|
|
}
|
|
|
|
it.each(["Ready", "Running"])("parses %s status metadata", async (status) => {
|
|
const runtime = await readRuntimeFromQueryOutput(
|
|
[
|
|
"TaskName: \\OpenClaw Gateway",
|
|
`Status: ${status}`,
|
|
"Last Run Time: 1/8/2026 1:23:45 AM",
|
|
"Last Run Result: 0x0",
|
|
].join("\r\n"),
|
|
);
|
|
expect(runtime).toMatchObject({
|
|
state: status,
|
|
lastRunTime: "1/8/2026 1:23:45 AM",
|
|
lastRunResult: "0x0",
|
|
});
|
|
});
|
|
|
|
it("parses 'Last Result' key variant (without 'Run') (#47726)", async () => {
|
|
const runtime = await readRuntimeFromQueryOutput(
|
|
[
|
|
"TaskName: \\OpenClaw Gateway",
|
|
"Status: Running",
|
|
"Last Run Time: 2026/3/16 8:34:15",
|
|
"Last Result: 267009",
|
|
].join("\r\n"),
|
|
);
|
|
expect(runtime).toMatchObject({
|
|
status: "running",
|
|
state: "Running",
|
|
lastRunTime: "2026/3/16 8:34:15",
|
|
lastRunResult: "267009",
|
|
});
|
|
});
|
|
|
|
it("treats Running + 0x41301 as running", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: Running", "Last Run Result: 0x41301"])),
|
|
).resolves.toMatchObject({ status: "running" });
|
|
});
|
|
|
|
it("treats Running + decimal 267009 as running", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: Running", "Last Run Result: 267009"])),
|
|
).resolves.toMatchObject({ status: "running" });
|
|
});
|
|
|
|
it("treats Running without numeric result as unknown", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: Running"])),
|
|
).resolves.toMatchObject({
|
|
status: "unknown",
|
|
detail: "Task status is locale-dependent and no numeric Last Run Result was available.",
|
|
});
|
|
});
|
|
|
|
it("treats non-running result codes as stopped", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: Running", "Last Run Result: 0x0"])),
|
|
).resolves.toMatchObject({
|
|
status: "stopped",
|
|
detail: "Task Last Run Result=0x0; treating as not running.",
|
|
});
|
|
});
|
|
|
|
it("detects running via result code when status is localized (German)", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(
|
|
taskQueryOutput(["Status: Wird ausgeführt", "Last Run Result: 0x41301"]),
|
|
),
|
|
).resolves.toMatchObject({ status: "running" });
|
|
});
|
|
|
|
it("detects running via result code when status is localized (French)", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: En cours", "Last Run Result: 267009"])),
|
|
).resolves.toMatchObject({ status: "running" });
|
|
});
|
|
|
|
it("treats localized status as stopped when result code is not a running code", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(
|
|
taskQueryOutput(["Status: Wird ausgeführt", "Last Run Result: 0x0"]),
|
|
),
|
|
).resolves.toMatchObject({
|
|
status: "stopped",
|
|
detail: "Task Last Run Result=0x0; treating as not running.",
|
|
});
|
|
});
|
|
|
|
it("treats localized status without result code as unknown", async () => {
|
|
await expect(
|
|
readRuntimeFromQueryOutput(taskQueryOutput(["Status: Wird ausgeführt"])),
|
|
).resolves.toMatchObject({
|
|
status: "unknown",
|
|
detail: "Task status is locale-dependent and no numeric Last Run Result was available.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveTaskScriptPath", () => {
|
|
it.each([
|
|
{
|
|
name: "uses default path when OPENCLAW_PROFILE is unset",
|
|
env: { USERPROFILE: "C:\\Users\\test" },
|
|
expected: path.join("C:\\Users\\test", ".openclaw", "gateway.cmd"),
|
|
},
|
|
{
|
|
name: "uses profile-specific path when OPENCLAW_PROFILE is set to a custom value",
|
|
env: { USERPROFILE: "C:\\Users\\test", OPENCLAW_PROFILE: "jbphoenix" },
|
|
expected: path.join("C:\\Users\\test", ".openclaw-jbphoenix", "gateway.cmd"),
|
|
},
|
|
{
|
|
name: "prefers OPENCLAW_STATE_DIR over profile-derived defaults",
|
|
env: {
|
|
USERPROFILE: "C:\\Users\\test",
|
|
OPENCLAW_PROFILE: "rescue",
|
|
OPENCLAW_STATE_DIR: "C:\\State\\openclaw",
|
|
},
|
|
expected: path.join("C:\\State\\openclaw", "gateway.cmd"),
|
|
},
|
|
{
|
|
name: "falls back to HOME when USERPROFILE is not set",
|
|
env: { HOME: "/home/test", OPENCLAW_PROFILE: "default" },
|
|
expected: path.join("/home/test", ".openclaw", "gateway.cmd"),
|
|
},
|
|
{
|
|
name: "uses a custom task script file name inside the state directory",
|
|
env: {
|
|
USERPROFILE: "C:\\Users\\test",
|
|
OPENCLAW_TASK_SCRIPT_NAME: "gateway-node.cmd",
|
|
},
|
|
expected: path.join("C:\\Users\\test", ".openclaw", "gateway-node.cmd"),
|
|
},
|
|
])("$name", ({ env, expected }) => {
|
|
expect(resolveTaskScriptPath(env)).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
"../gateway.cmd",
|
|
"..\\gateway.cmd",
|
|
"nested/gateway.cmd",
|
|
"nested\\gateway.cmd",
|
|
"gateway..cmd",
|
|
])("rejects non-file task script name %s", (scriptName) => {
|
|
expect(() =>
|
|
resolveTaskScriptPath({
|
|
USERPROFILE: "C:\\Users\\test",
|
|
OPENCLAW_TASK_SCRIPT_NAME: scriptName,
|
|
}),
|
|
).toThrow("OPENCLAW_TASK_SCRIPT_NAME must be a file name only");
|
|
});
|
|
});
|
|
|
|
describe("readScheduledTaskCommand", () => {
|
|
async function withScheduledTaskScript(
|
|
options: {
|
|
scriptLines?: string[];
|
|
scriptEncoding?: "utf8" | "gbk";
|
|
env?:
|
|
| Record<string, string | undefined>
|
|
| ((tmpDir: string) => Record<string, string | undefined>);
|
|
},
|
|
run: (env: Record<string, string | undefined>) => Promise<void>,
|
|
) {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-schtasks-test-"));
|
|
try {
|
|
const extraEnv = typeof options.env === "function" ? options.env(tmpDir) : options.env;
|
|
const env = {
|
|
USERPROFILE: tmpDir,
|
|
OPENCLAW_PROFILE: "default",
|
|
...extraEnv,
|
|
};
|
|
if (options.scriptLines) {
|
|
const scriptPath = resolveTaskScriptPath(env);
|
|
const script = options.scriptLines.join("\r\n");
|
|
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
|
let scriptBytes: Buffer = Buffer.from(script, "utf8");
|
|
if (options.scriptEncoding === "gbk") {
|
|
// Production bytes for a code-page install: marker line + GBK body.
|
|
resolveWindowsOemEncodingMock.mockReturnValueOnce("gbk");
|
|
scriptBytes = encodeWindowsLauncherScript({ format: "cmd", content: script });
|
|
}
|
|
await fs.writeFile(scriptPath, scriptBytes);
|
|
}
|
|
await run(env);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
it("parses script with quoted arguments containing spaces", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
// Use forward slashes which work in Windows cmd and avoid escape parsing issues.
|
|
scriptLines: ["@echo off", '"C:/Program Files/Node/node.exe" gateway.js'],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toEqual({
|
|
programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"],
|
|
sourcePath: resolveTaskScriptPath(env),
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("returns null when script has no command", async () => {
|
|
await withScheduledTaskScript(
|
|
{ scriptLines: ["@echo off", "rem This is just a comment"] },
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toBeNull();
|
|
},
|
|
);
|
|
});
|
|
|
|
it("parses full script with all components", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
scriptLines: [
|
|
"@echo off",
|
|
"rem OpenClaw Gateway",
|
|
"cd /d C:\\Projects\\openclaw",
|
|
"set NODE_ENV=production",
|
|
"set OPENCLAW_PORT=18789",
|
|
"node gateway.js --verbose",
|
|
],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toEqual({
|
|
programArguments: ["node", "gateway.js", "--verbose"],
|
|
workingDirectory: "C:\\Projects\\openclaw",
|
|
environment: {
|
|
NODE_ENV: "production",
|
|
OPENCLAW_PORT: "18789",
|
|
},
|
|
environmentValueSources: {
|
|
NODE_ENV: "inline",
|
|
OPENCLAW_PORT: "inline",
|
|
},
|
|
sourcePath: resolveTaskScriptPath(env),
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("parses command with Windows backslash paths", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
scriptLines: [
|
|
"@echo off",
|
|
'"C:\\Program Files\\nodejs\\node.exe" C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js gateway --port 18789',
|
|
],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toEqual({
|
|
programArguments: [
|
|
"C:\\Program Files\\nodejs\\node.exe",
|
|
"C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js",
|
|
"gateway",
|
|
"--port",
|
|
"18789",
|
|
],
|
|
sourcePath: resolveTaskScriptPath(env),
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("preserves UNC paths in command arguments", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
scriptLines: [
|
|
"@echo off",
|
|
'"\\\\fileserver\\OpenClaw Share\\node.exe" "\\\\fileserver\\OpenClaw Share\\dist\\index.js" gateway --port 18789',
|
|
],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toEqual({
|
|
programArguments: [
|
|
"\\\\fileserver\\OpenClaw Share\\node.exe",
|
|
"\\\\fileserver\\OpenClaw Share\\dist\\index.js",
|
|
"gateway",
|
|
"--port",
|
|
"18789",
|
|
],
|
|
sourcePath: resolveTaskScriptPath(env),
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("reads script from OPENCLAW_STATE_DIR override", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
env: (tmpDir) => ({ OPENCLAW_STATE_DIR: path.join(tmpDir, "custom-state") }),
|
|
scriptLines: ["@echo off", "node gateway.js --from-state-dir"],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result).toEqual({
|
|
programArguments: ["node", "gateway.js", "--from-state-dir"],
|
|
sourcePath: resolveTaskScriptPath(env),
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it("parses quoted set assignments with escaped metacharacters", async () => {
|
|
await withScheduledTaskScript(
|
|
{
|
|
scriptLines: [
|
|
"@echo off",
|
|
'set "OC_AMP=left & right"',
|
|
'set "OC_PIPE=a | b"',
|
|
'set "OC_CARET=^^"',
|
|
'set "OC_PERCENT=%%TEMP%%"',
|
|
'set "OC_BANG=^!token^!"',
|
|
'set "OC_QUOTE=he said ^"hi^""',
|
|
"node gateway.js --verbose",
|
|
],
|
|
},
|
|
async (env) => {
|
|
const result = await readScheduledTaskCommand(env);
|
|
expect(result?.environment).toEqual({
|
|
OC_AMP: "left & right",
|
|
OC_PIPE: "a | b",
|
|
OC_CARET: "^",
|
|
OC_PERCENT: "%TEMP%",
|
|
OC_BANG: "!token!",
|
|
OC_QUOTE: 'he said "hi"',
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|