Skip to content

Commit d92a333

Browse files
MoerAIsteipete
authored andcommitted
fix(daemon): keep Windows Scheduled Task running on battery power (#59299)
The Windows Gateway daemon crashes (or rather is killed by Task Scheduler) every time the laptop unplugs from AC power. Reporter on Windows 10 22H2 documented a 100% failure rate. Root cause: `activateScheduledTask` in `src/daemon/schtasks.ts` used `schtasks /Create` with CLI flags (`/SC ONLOGON /RL LIMITED /TR ...`). That CLI surface cannot set `<DisallowStartIfOnBatteries>` or `<StopIfGoingOnBatteries>`, so the task inherits the Task Scheduler defaults (both `true`), which prevent the task from starting on battery and stop it when AC power is lost mid-run. This change switches `/Create` to `/Create /XML <tempfile>` and emits a Task Scheduler XML payload that mirrors the prior CLI flags (ONLOGON trigger, LeastPrivilege run level, InteractiveToken logon when a `taskUser` is resolved, single-instance policy, no idle restrictions, exec action wired to the existing `gateway.cmd` / `gateway.vbs` launcher) AND sets: <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> The XML is written as UTF-16 LE with a BOM, which is what `schtasks /XML` expects on all Windows locales. The temp file is cleaned up in a `finally` block. The same XML re-apply is also issued from `updateExistingScheduledTask` after the existing `/Change /TR` call, so users upgrading from older versions inherit the new battery flags on the next gateway install/refresh instead of staying broken until a full uninstall+reinstall. This follows clawsweeper's direction on #59299: "Land a narrow Windows Scheduled Task settings repair that lets the Gateway task start and continue on battery while preserving the current Startup-folder fallback, hidden launcher, quoting, and update behavior." Preserved unchanged: - Startup-folder fallback when `/Create` is denied or times out - Hidden launcher (.vbs) selection via `OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER` - `quoteSchtasksArg` quoting strategy for the script launch path - `/Change` update path semantics (still updates `/TR` first) - All `runScheduledTaskOrThrow` and fallback launch behavior downstream Verification: - `node scripts/run-vitest.mjs src/daemon/schtasks.install.test.ts` — 12 passed (incl. 2 new battery-flag regression tests) - `node scripts/run-vitest.mjs src/daemon/schtasks.test.ts src/daemon/schtasks.startup-fallback.test.ts src/daemon/schtasks.stop.test.ts src/daemon/schtasks-exec.test.ts` — 54 passed (sibling daemon coverage) - `pnpm tsgo:core` — passed (production typecheck) Closes #59299
1 parent b75f70b commit d92a333

2 files changed

Lines changed: 245 additions & 32 deletions

File tree

src/daemon/schtasks.install.test.ts

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,36 @@ import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.
88

99
const schtasksCalls: string[][] = [];
1010
const schtasksResponses: { code: number; stdout: string; stderr: string }[] = [];
11+
// Captures the XML payload at /Create /XML time before the production code's
12+
// `finally` block deletes the temp file. Indexed by the position in
13+
// `schtasksCalls` so individual tests can pin which create-call they assert on.
14+
const xmlPayloadCaptures: Array<{ index: number; xml: string }> = [];
1115

1216
vi.mock("./schtasks-exec.js", () => ({
1317
execSchtasks: async (argv: string[]) => {
18+
const index = schtasksCalls.length;
1419
schtasksCalls.push(argv);
20+
const xmlFlagPos = argv.indexOf("/XML");
21+
if (xmlFlagPos !== -1) {
22+
const xmlPath = argv[xmlFlagPos + 1];
23+
if (typeof xmlPath === "string") {
24+
try {
25+
const raw = await fs.readFile(xmlPath);
26+
// Strip the UTF-16 LE BOM and decode for readable assertions.
27+
xmlPayloadCaptures.push({ index, xml: raw.slice(2).toString("utf16le") });
28+
} catch {
29+
// Mock cannot block production cleanup; tests assert via captured payloads.
30+
}
31+
}
32+
}
1533
return schtasksResponses.shift() ?? { code: 0, stdout: "", stderr: "" };
1634
},
1735
}));
1836

1937
beforeEach(() => {
2038
schtasksCalls.length = 0;
2139
schtasksResponses.length = 0;
40+
xmlPayloadCaptures.length = 0;
2241
});
2342

2443
describe("installScheduledTask", () => {
@@ -135,7 +154,15 @@ describe("installScheduledTask", () => {
135154
expect(schtasksCalls[0]).toEqual(["/Query"]);
136155
expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]);
137156
expect(schtasksCalls[2]?.[0]).toBe("/Change");
138-
expect(schtasksCalls[3]).toEqual(["/Run", "/TN", "OpenClaw Gateway"]);
157+
// Battery-flag XML re-apply runs between /Change and /Run on upgrades.
158+
expect(schtasksCalls[3]?.slice(0, 5)).toEqual([
159+
"/Create",
160+
"/F",
161+
"/TN",
162+
"OpenClaw Gateway",
163+
"/XML",
164+
]);
165+
expect(schtasksCalls[4]).toEqual(["/Run", "/TN", "OpenClaw Gateway"]);
139166
});
140167
});
141168

@@ -197,32 +224,94 @@ describe("installScheduledTask", () => {
197224
const launcher = await fs.readFile(launcherPath, "utf8");
198225

199226
expectInitialTaskQueries();
200-
expect(schtasksCalls[2]).toEqual([
227+
// `/Create /XML` argv shape: ["/Create", "/F", "/TN", "<name>", "/XML", "<path>", "/RU", "<user>", "/NP"].
228+
// The XML payload is what carries the SC, RL, TR, and battery settings now.
229+
expect(schtasksCalls[2]?.slice(0, 5)).toEqual([
201230
"/Create",
202231
"/F",
203-
"/SC",
204-
"ONLOGON",
205-
"/RL",
206-
"LIMITED",
207232
"/TN",
208233
"OpenClaw Gateway",
209-
"/TR",
210-
expect.stringContaining("gateway.vbs"),
211-
"/RU",
212-
"WORKSTATION\\alice",
213-
"/NP",
214-
"/IT",
234+
"/XML",
215235
]);
236+
expect(schtasksCalls[2]?.slice(6)).toEqual(["/RU", "WORKSTATION\\alice", "/NP"]);
216237
expect(launcher).toContain("WScript.Shell");
217238
expect(launcher).toContain(scriptPath);
218239
expect(launcher).toContain(`Run """${scriptPath}""", 0, False`);
219240
expectTaskRunCall(3);
220241
});
221242
});
222243

244+
it("creates the Scheduled Task via XML with battery start/continue enabled (#59299)", async () => {
245+
await withUserProfileDir(async (_tmpDir, env) => {
246+
schtasksResponses.push(okSchtasksResponse, missingTaskResponse);
247+
248+
await installDefaultGatewayTask({
249+
...env,
250+
USERDOMAIN: "WORKSTATION",
251+
USERNAME: "alice",
252+
});
253+
254+
// `/Create` must use `/XML <path>` so battery flags can be set; the
255+
// CLI flag form (`/SC ONLOGON /RL LIMITED /TR ...`) cannot express
256+
// `DisallowStartIfOnBatteries`/`StopIfGoingOnBatteries`.
257+
const createCall = schtasksCalls[2];
258+
expect(createCall?.[0]).toBe("/Create");
259+
expect(createCall).toContain("/XML");
260+
261+
const captured = xmlPayloadCaptures.find((entry) => entry.index === 2);
262+
expect(captured).toBeDefined();
263+
const xml = captured?.xml ?? "";
264+
expect(xml).toContain("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>");
265+
expect(xml).toContain("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>");
266+
// Preserve the prior CLI semantics: ONLOGON trigger, LeastPrivilege, exec action.
267+
expect(xml).toContain("<LogonTrigger>");
268+
expect(xml).toContain("<RunLevel>LeastPrivilege</RunLevel>");
269+
expect(xml).toContain("<UserId>WORKSTATION\\alice</UserId>");
270+
expect(xml).toContain("<Exec>");
271+
});
272+
});
273+
274+
it("re-applies the XML on /Change so upgraded tasks adopt battery flags (#59299)", async () => {
275+
await withUserProfileDir(async (_tmpDir, env) => {
276+
// /Query yes, /Query /TN yes, /Change ok, /Create /XML ok (upgrade), /Run ok.
277+
schtasksResponses.push(
278+
okSchtasksResponse,
279+
okSchtasksResponse,
280+
okSchtasksResponse,
281+
okSchtasksResponse,
282+
okSchtasksResponse,
283+
);
284+
285+
await installDefaultGatewayTask(env);
286+
287+
expectInitialTaskQueries();
288+
expect(schtasksCalls[2]?.[0]).toBe("/Change");
289+
expect(schtasksCalls[3]?.slice(0, 5)).toEqual([
290+
"/Create",
291+
"/F",
292+
"/TN",
293+
"OpenClaw Gateway",
294+
"/XML",
295+
]);
296+
const upgradeCapture = xmlPayloadCaptures.find((entry) => entry.index === 3);
297+
expect(upgradeCapture).toBeDefined();
298+
const upgradeXml = upgradeCapture?.xml ?? "";
299+
expect(upgradeXml).toContain("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>");
300+
expect(upgradeXml).toContain("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>");
301+
expectTaskRunCall(4);
302+
});
303+
});
304+
223305
it("updates existing tasks to use the hidden launcher when requested", async () => {
224306
await withUserProfileDir(async (_tmpDir, env) => {
225-
schtasksResponses.push(okSchtasksResponse, okSchtasksResponse, okSchtasksResponse);
307+
// /Query, /Query /TN, /Change (TR-only), /Create /XML (upgrade re-apply), /Run.
308+
schtasksResponses.push(
309+
okSchtasksResponse,
310+
okSchtasksResponse,
311+
okSchtasksResponse,
312+
okSchtasksResponse,
313+
okSchtasksResponse,
314+
);
226315

227316
const { scriptPath } = await installDefaultGatewayTask({
228317
...env,
@@ -241,7 +330,15 @@ describe("installScheduledTask", () => {
241330
expect.stringContaining("gateway.vbs"),
242331
]);
243332
expect(schtasksCalls[2]?.[4]).toContain(launcherPath);
244-
expectTaskRunCall(3);
333+
// Upgrade XML re-apply runs after /Change so older tasks pick up battery flags.
334+
expect(schtasksCalls[3]?.slice(0, 5)).toEqual([
335+
"/Create",
336+
"/F",
337+
"/TN",
338+
"OpenClaw Gateway",
339+
"/XML",
340+
]);
341+
expectTaskRunCall(4);
245342
});
246343
});
247344

@@ -260,10 +357,12 @@ describe("installScheduledTask", () => {
260357

261358
it("throws when /Run fails after updating an existing task", async () => {
262359
await withUserProfileDir(async (_tmpDir, env) => {
360+
// /Query, /Query /TN, /Change, /Create XML upgrade re-apply, /Run (fails).
263361
schtasksResponses.push(
264362
okSchtasksResponse,
265363
okSchtasksResponse,
266364
okSchtasksResponse,
365+
okSchtasksResponse,
267366
accessDeniedResponse,
268367
);
269368

@@ -273,7 +372,8 @@ describe("installScheduledTask", () => {
273372

274373
expectInitialTaskQueries();
275374
expect(schtasksCalls[2]?.[0]).toBe("/Change");
276-
expectTaskRunCall(3);
375+
expect(schtasksCalls[3]?.[0]).toBe("/Create");
376+
expectTaskRunCall(4);
277377
});
278378
});
279379

src/daemon/schtasks.ts

Lines changed: 130 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn, spawnSync } from "node:child_process";
22
import fs from "node:fs/promises";
3+
import os from "node:os";
34
import path from "node:path";
45
import { isGatewayArgv } from "../infra/gateway-process-argv.js";
56
import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js";
@@ -103,6 +104,93 @@ function quoteSchtasksArg(value: string): string {
103104
return `"${value.replace(/"/g, '\\"')}"`;
104105
}
105106

107+
// XML 1.0 text-node escape for Task Scheduler payloads. `<Command>`, `<Arguments>`,
108+
// `<Description>`, and `<UserId>` accept any literal user/script path, so the
109+
// only characters that need encoding are XML structural ones. CR/LF are already
110+
// rejected upstream in `assertNoCmdLineBreak`.
111+
function escapeXmlText(value: string): string {
112+
return value
113+
.replace(/&/g, "&amp;")
114+
.replace(/</g, "&lt;")
115+
.replace(/>/g, "&gt;")
116+
.replace(/"/g, "&quot;")
117+
.replace(/'/g, "&apos;");
118+
}
119+
120+
// Task Scheduler XML payload for `schtasks /Create /XML`. We switched off the
121+
// CLI flag form to set `<DisallowStartIfOnBatteries>` and `<StopIfGoingOnBatteries>`
122+
// to `false`, which the `schtasks /Create` and `/Change` CLI surfaces do not
123+
// expose. The CLI default leaves both at `true`, which kills the Gateway task
124+
// when a laptop unplugs from AC power (#59299). The rest of the XML mirrors
125+
// the prior CLI flags: ONLOGON trigger, LeastPrivilege run level, single-instance
126+
// policy, no idle restrictions, and the same `<Exec>` action wired to the
127+
// existing `gateway.cmd` / `gateway.vbs` launcher.
128+
function buildScheduledTaskXml(params: {
129+
taskDescription: string;
130+
taskUser: string | null;
131+
launchPath: string;
132+
}): string {
133+
const description = escapeXmlText(params.taskDescription);
134+
const command = escapeXmlText(params.launchPath);
135+
const principalLogon = params.taskUser
136+
? `\n <UserId>${escapeXmlText(params.taskUser)}</UserId>\n <LogonType>InteractiveToken</LogonType>`
137+
: "\n <GroupId>S-1-5-32-545</GroupId>";
138+
const triggerUser = params.taskUser
139+
? `\n <UserId>${escapeXmlText(params.taskUser)}</UserId>`
140+
: "";
141+
return `<?xml version="1.0" encoding="UTF-16"?>
142+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
143+
<RegistrationInfo>
144+
<Description>${description}</Description>
145+
</RegistrationInfo>
146+
<Triggers>
147+
<LogonTrigger>
148+
<Enabled>true</Enabled>${triggerUser}
149+
</LogonTrigger>
150+
</Triggers>
151+
<Principals>
152+
<Principal id="Author">${principalLogon}
153+
<RunLevel>LeastPrivilege</RunLevel>
154+
</Principal>
155+
</Principals>
156+
<Settings>
157+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
158+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
159+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
160+
<AllowHardTerminate>true</AllowHardTerminate>
161+
<StartWhenAvailable>false</StartWhenAvailable>
162+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
163+
<IdleSettings>
164+
<StopOnIdleEnd>false</StopOnIdleEnd>
165+
<RestartOnIdle>false</RestartOnIdle>
166+
</IdleSettings>
167+
<AllowStartOnDemand>true</AllowStartOnDemand>
168+
<Enabled>true</Enabled>
169+
<Hidden>false</Hidden>
170+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
171+
<WakeToRun>false</WakeToRun>
172+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
173+
<Priority>7</Priority>
174+
</Settings>
175+
<Actions Context="Author">
176+
<Exec>
177+
<Command>${command}</Command>
178+
</Exec>
179+
</Actions>
180+
</Task>`;
181+
}
182+
183+
async function writeTaskXmlTempFile(xml: string): Promise<string> {
184+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-task-xml-"));
185+
const xmlPath = path.join(tmpDir, "task.xml");
186+
// schtasks /XML expects UTF-16 LE with BOM; Node's "utf16le" Buffer plus a
187+
// manual FFFE BOM matches what Task Scheduler import accepts on all locales.
188+
const bom = Buffer.from([0xff, 0xfe]);
189+
const body = Buffer.from(xml, "utf16le");
190+
await fs.writeFile(xmlPath, Buffer.concat([bom, body]));
191+
return xmlPath;
192+
}
193+
106194
function resolveTaskUser(env: GatewayServiceEnv): string | null {
107195
const username = env.USERNAME || env.USER || env.LOGNAME;
108196
if (!username) {
@@ -873,6 +961,8 @@ async function updateExistingScheduledTask(params: {
873961
taskName: string;
874962
quotedLaunchPath: string;
875963
scriptPath: string;
964+
taskLaunchPath: string;
965+
description?: string;
876966
}): Promise<boolean> {
877967
if (!(await isRegisteredScheduledTask(params.env))) {
878968
return false;
@@ -887,6 +977,23 @@ async function updateExistingScheduledTask(params: {
887977
if (change.code !== 0) {
888978
return false;
889979
}
980+
// Re-apply the full XML on top of the `/Change` so tasks installed by older
981+
// versions inherit the `<DisallowStartIfOnBatteries>false</...>` and
982+
// `<StopIfGoingOnBatteries>false</...>` flags on upgrade (#59299). Best
983+
// effort: a non-zero result here leaves the existing settings in place, so
984+
// upgraders keep the prior buggy defaults rather than losing the task.
985+
const upgradeXmlPath = await writeTaskXmlTempFile(
986+
buildScheduledTaskXml({
987+
taskDescription: params.description ?? "OpenClaw Gateway",
988+
taskUser: resolveTaskUser(params.env),
989+
launchPath: params.taskLaunchPath,
990+
}),
991+
);
992+
try {
993+
await execSchtasks(["/Create", "/F", "/TN", params.taskName, "/XML", upgradeXmlPath]);
994+
} finally {
995+
await fs.rm(path.dirname(upgradeXmlPath), { recursive: true, force: true }).catch(() => {});
996+
}
890997
await runScheduledTaskOrThrow({
891998
taskName: params.taskName,
892999
env: params.env,
@@ -1047,25 +1154,31 @@ async function activateScheduledTask(params: {
10471154
return;
10481155
}
10491156

1050-
const baseArgs = [
1051-
"/Create",
1052-
"/F",
1053-
"/SC",
1054-
"ONLOGON",
1055-
"/RL",
1056-
"LIMITED",
1057-
"/TN",
1058-
taskName,
1059-
"/TR",
1060-
quotedLaunchPath,
1061-
];
10621157
const taskUser = resolveTaskUser(params.env);
1063-
const taskUserArgs = taskUser ? ["/RU", taskUser, "/NP", "/IT"] : [];
1064-
let create = await execSchtasks(
1065-
taskUserArgs.length > 0 ? [...baseArgs, ...taskUserArgs] : baseArgs,
1158+
// Use `schtasks /Create /XML` so the task carries explicit
1159+
// `DisallowStartIfOnBatteries=false` and `StopIfGoingOnBatteries=false`
1160+
// settings. The CLI flag form (`/Create /SC ONLOGON ...`) cannot set those
1161+
// flags and inherits the Task Scheduler defaults (both true), which kills
1162+
// the Gateway when a laptop unplugs from AC power (#59299).
1163+
const xmlPath = await writeTaskXmlTempFile(
1164+
buildScheduledTaskXml({
1165+
taskDescription,
1166+
taskUser,
1167+
launchPath: params.taskLaunchPath,
1168+
}),
10661169
);
1067-
if (create.code !== 0 && taskUser) {
1068-
create = await execSchtasks(baseArgs);
1170+
let create: Awaited<ReturnType<typeof execSchtasks>>;
1171+
try {
1172+
const xmlArgs = ["/Create", "/F", "/TN", taskName, "/XML", xmlPath];
1173+
const xmlArgsWithUser = taskUser ? [...xmlArgs, "/RU", taskUser, "/NP"] : xmlArgs;
1174+
create = await execSchtasks(xmlArgsWithUser);
1175+
if (create.code !== 0 && taskUser) {
1176+
// Retry without the elevated `/RU` form, matching the pre-XML behavior
1177+
// for accounts whose service password cannot be stored.
1178+
create = await execSchtasks(xmlArgs);
1179+
}
1180+
} finally {
1181+
await fs.rm(path.dirname(xmlPath), { recursive: true, force: true }).catch(() => {});
10691182
}
10701183
if (create.code !== 0) {
10711184
const detail = create.stderr || create.stdout;

0 commit comments

Comments
 (0)