akm-cli 0.9.0-rc.3 → 0.9.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -4
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/cli.js +25 -10
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/improve-result.js +87 -7
- package/dist/core/state-db.js +1 -0
- package/dist/scripts/migrate-storage.js +21 -3
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +4 -2
- package/docs/migration/v0.8-to-v0.9.md +64 -9
- package/package.json +2 -3
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* • `LogonType=InteractiveToken` means the task runs in the context of
|
|
14
14
|
* the registering user only when they are logged in — there is no
|
|
15
15
|
* stored password and the task will not fire at the lock screen.
|
|
16
|
-
* • `<Principal>`
|
|
17
|
-
*
|
|
18
|
-
* defaults to the registering user.
|
|
16
|
+
* • `<Principal>` records the current user SID so sync can detect identity
|
|
17
|
+
* drift instead of silently accepting a task registered to another user.
|
|
19
18
|
* • `<DisallowStartIfOnBatteries>false</…>` and `<StopIfGoingOnBatteries>
|
|
20
19
|
* false</…>` allow the task to run on battery — utility tasks would
|
|
21
20
|
* otherwise be silently skipped on laptops.
|
|
@@ -29,6 +28,7 @@
|
|
|
29
28
|
*
|
|
30
29
|
* Tests inject a fake exec + filesystem.
|
|
31
30
|
*/
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
32
|
import fs from "node:fs";
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
@@ -37,33 +37,87 @@ import { ConfigError } from "../../core/errors.js";
|
|
|
37
37
|
import { getTaskLogDir } from "../../core/paths.js";
|
|
38
38
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
39
39
|
import { parseSchedule, translateToSchtasks } from "../schedule.js";
|
|
40
|
-
import {
|
|
40
|
+
import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
|
|
41
|
+
import { escapeXml, nodeExec, nodeFs, normalizeXmlForUtf16File } from "./exec-utils.js";
|
|
41
42
|
export const DEFAULT_FOLDER_PREFIX = "\\akm\\";
|
|
43
|
+
const SIGNATURE_PREFIX = "akm:v1:";
|
|
42
44
|
export function SCHTASKS_BACKEND(options = {}) {
|
|
43
45
|
const exec = options.exec ?? defaultSchtasksExec();
|
|
44
46
|
const fsLike = options.fs ?? defaultSchtasksFs();
|
|
45
47
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
46
48
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
47
49
|
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
|
|
50
|
+
const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
|
|
51
|
+
const userSid = options.userSid ?? resolveCurrentUserSid(exec);
|
|
48
52
|
const taskName = (id) => `${folder}${id}`;
|
|
49
53
|
return {
|
|
50
54
|
name: "schtasks",
|
|
51
55
|
install(task) {
|
|
56
|
+
const xml = normalizeXmlForUtf16File(buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder, scheduledContext, userSid }));
|
|
57
|
+
const query = exec.run(["schtasks", "/Query", "/TN", taskName(task.id), "/XML"]);
|
|
58
|
+
let previous;
|
|
59
|
+
if (query.status === 0) {
|
|
60
|
+
const enabled = taskXmlEnabled(query.stdout);
|
|
61
|
+
if (enabled === undefined) {
|
|
62
|
+
throw new ConfigError(`schtasks /Query returned an unreadable definition for "${taskName(task.id)}"; refusing to replace it.`, "INVALID_CONFIG_FILE");
|
|
63
|
+
}
|
|
64
|
+
previous = { xml: normalizeXmlForUtf16File(query.stdout), enabled };
|
|
65
|
+
}
|
|
66
|
+
else if (!isMissingTaskResult(query)) {
|
|
67
|
+
throw new ConfigError(`schtasks /Query failed (exit ${query.status}): ${query.stderr || query.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
68
|
+
}
|
|
52
69
|
fsLike.ensureDir(logDir);
|
|
53
|
-
const xml = buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder });
|
|
54
70
|
const tmpFile = path.join(fsLike.tmpdir(), `akm-task-${task.id}-${Date.now()}.xml`);
|
|
55
71
|
fsLike.writeFile(tmpFile, xml);
|
|
56
72
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
try {
|
|
74
|
+
// /F forces overwrite if a task with the same name exists.
|
|
75
|
+
const r = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
|
|
76
|
+
if (r.status !== 0) {
|
|
77
|
+
throw new ConfigError(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
78
|
+
}
|
|
79
|
+
if (!task.enabled) {
|
|
80
|
+
const dis = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), "/DISABLE"]);
|
|
81
|
+
if (dis.status !== 0) {
|
|
82
|
+
throw new ConfigError(`schtasks /Change /DISABLE failed: ${dis.stderr || dis.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
61
85
|
}
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
86
|
+
catch (err) {
|
|
87
|
+
const rollbackErrors = [];
|
|
88
|
+
if (previous === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
const remove = exec.run(["schtasks", "/Delete", "/TN", taskName(task.id), "/F"]);
|
|
91
|
+
if (remove.status !== 0 && !isMissingTaskResult(remove)) {
|
|
92
|
+
rollbackErrors.push(new ConfigError(`schtasks /Delete during rollback failed: ${remove.stderr || remove.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (rollbackError) {
|
|
96
|
+
rollbackErrors.push(rollbackError);
|
|
97
|
+
}
|
|
66
98
|
}
|
|
99
|
+
else {
|
|
100
|
+
try {
|
|
101
|
+
fsLike.writeFile(tmpFile, previous.xml);
|
|
102
|
+
const restore = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
|
|
103
|
+
if (restore.status !== 0) {
|
|
104
|
+
throw new ConfigError(`schtasks /Create during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
105
|
+
}
|
|
106
|
+
const stateFlag = previous.enabled ? "/ENABLE" : "/DISABLE";
|
|
107
|
+
const state = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), stateFlag]);
|
|
108
|
+
if (state.status !== 0) {
|
|
109
|
+
throw new ConfigError(`schtasks /Change ${stateFlag} during rollback failed: ${state.stderr || state.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (rollbackError) {
|
|
113
|
+
rollbackErrors.push(rollbackError);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (rollbackErrors.length > 0) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for Task Scheduler task "${task.id}" was incomplete.`);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
67
121
|
}
|
|
68
122
|
}
|
|
69
123
|
finally {
|
|
@@ -85,8 +139,9 @@ export function SCHTASKS_BACKEND(options = {}) {
|
|
|
85
139
|
},
|
|
86
140
|
list() {
|
|
87
141
|
const r = exec.run(["schtasks", "/Query", "/FO", "CSV", "/NH"]);
|
|
88
|
-
if (r.status !== 0)
|
|
89
|
-
|
|
142
|
+
if (r.status !== 0) {
|
|
143
|
+
throw new ConfigError(`schtasks /Query failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
144
|
+
}
|
|
90
145
|
const ids = [];
|
|
91
146
|
for (const line of (r.stdout ?? "").split(/\r?\n/)) {
|
|
92
147
|
const m = line.match(/^"([^"]+)",/);
|
|
@@ -97,58 +152,106 @@ export function SCHTASKS_BACKEND(options = {}) {
|
|
|
97
152
|
ids.push(name.slice(folder.length));
|
|
98
153
|
}
|
|
99
154
|
}
|
|
100
|
-
return ids.map((id) =>
|
|
155
|
+
return ids.map((id) => {
|
|
156
|
+
const query = exec.run(["schtasks", "/Query", "/TN", taskName(id), "/XML"]);
|
|
157
|
+
if (query.status !== 0) {
|
|
158
|
+
throw new ConfigError(`schtasks /Query /XML for "${taskName(id)}" failed (exit ${query.status}): ${query.stderr || query.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
159
|
+
}
|
|
160
|
+
const signature = installedSignature(query.stdout);
|
|
161
|
+
return signature === undefined ? { id } : { id, signature };
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
expectedSignature(task) {
|
|
165
|
+
const signature = taskXmlSignature(buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder, scheduledContext, userSid }));
|
|
166
|
+
if (signature === undefined)
|
|
167
|
+
throw new Error("Failed to fingerprint generated Task Scheduler XML.");
|
|
168
|
+
return signature;
|
|
101
169
|
},
|
|
102
170
|
};
|
|
103
171
|
}
|
|
104
|
-
export function buildSchtasksXml(task, akmArgv, logDir, options
|
|
172
|
+
export function buildSchtasksXml(task, akmArgv, logDir, options) {
|
|
105
173
|
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
|
|
106
174
|
const now = options.now ? options.now() : new Date();
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const trigger = translateToSchtasks(spec);
|
|
110
|
-
const command = akmArgv[0];
|
|
111
|
-
const args = [...akmArgv.slice(1), "tasks", "run", task.id].map((a) => quoteArg(a)).join(" ");
|
|
112
|
-
const triggerXml = renderSchtasksTrigger(trigger, startBoundary);
|
|
113
|
-
const logPath = path.join(logDir, `${task.id}.log`);
|
|
175
|
+
const definition = buildSchtasksDefinition(task, akmArgv, logDir, folder, options.scheduledContext, options.userSid);
|
|
176
|
+
const triggerXml = renderSchtasksTrigger(definition.trigger, now);
|
|
114
177
|
return schtasksTemplate
|
|
115
178
|
.replaceAll("{{TASK_ID}}", escapeXml(task.id))
|
|
116
179
|
.replaceAll("{{FOLDER}}", escapeXml(folder))
|
|
180
|
+
.replace("{{SIGNATURE}}", definition.signature)
|
|
117
181
|
.replace("{{TRIGGER_XML}}", triggerXml)
|
|
182
|
+
.replace('<Principal id="Author">', `<Principal id="Author">\n <UserId>${escapeXml(options.userSid)}</UserId>`)
|
|
118
183
|
.replace("{{ENABLED}}", task.enabled ? "true" : "false")
|
|
119
|
-
.replace("{{COMMAND}}", escapeXml(command))
|
|
120
|
-
.replace("{{ARGS}}", escapeXml(args))
|
|
121
|
-
.replace("{{LOG_PATH}}", escapeXml(logPath));
|
|
184
|
+
.replace("{{COMMAND}}", escapeXml(definition.command))
|
|
185
|
+
.replace("{{ARGS}}", escapeXml(definition.args))
|
|
186
|
+
.replace("{{LOG_PATH}}", escapeXml(definition.logPath));
|
|
187
|
+
}
|
|
188
|
+
function buildSchtasksDefinition(task, akmArgv, logDir, folder, scheduledContext, userSid) {
|
|
189
|
+
const spec = parseSchedule(task.schedule, "schtasks");
|
|
190
|
+
const trigger = translateToSchtasks(spec);
|
|
191
|
+
const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
|
|
192
|
+
const environment = Object.entries(invocation.environment)
|
|
193
|
+
.map(([key, value]) => `$env:${key}=${quotePowerShell(value)}`)
|
|
194
|
+
.join("; ");
|
|
195
|
+
const invoke = `& ${invocation.argv.map((arg) => quotePowerShell(arg)).join(" ")}`;
|
|
196
|
+
const script = `${environment}; ${invoke}; exit $LASTEXITCODE`;
|
|
197
|
+
const command = "powershell.exe";
|
|
198
|
+
const args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script].map(quoteArg).join(" ");
|
|
199
|
+
const logPath = path.join(logDir, `${task.id}.log`);
|
|
200
|
+
// The boundary changes on reinstall, and enabled state can change via /Change.
|
|
201
|
+
// Keep both outside the stored definition fingerprint so no-op sync stays stable.
|
|
202
|
+
const fingerprint = createHash("sha256")
|
|
203
|
+
.update(JSON.stringify({ folder, id: task.id, trigger, command, args, logPath, userSid }))
|
|
204
|
+
.digest("hex");
|
|
205
|
+
return { trigger, command, args, logPath, signature: `${SIGNATURE_PREFIX}${fingerprint}` };
|
|
206
|
+
}
|
|
207
|
+
function renderSchtasksTrigger(trigger, now) {
|
|
208
|
+
return expandNativeTriggers(trigger)
|
|
209
|
+
.map((native) => renderNativeTrigger(native, formatStartBoundary(nextStartBoundary(native, now))))
|
|
210
|
+
.join("\n");
|
|
122
211
|
}
|
|
123
|
-
function
|
|
212
|
+
function expandNativeTriggers(trigger) {
|
|
124
213
|
switch (trigger.kind) {
|
|
125
214
|
case "minute":
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</Repetition>
|
|
130
|
-
<StartBoundary>${startBoundary}</StartBoundary>
|
|
131
|
-
<Enabled>true</Enabled>
|
|
132
|
-
</TimeTrigger>`;
|
|
215
|
+
return [{ kind: "daily", atHour: 0, atMinute: 0, repeatEveryMinutes: trigger.everyMinutes }];
|
|
216
|
+
case "minuteValues":
|
|
217
|
+
return trigger.minutes.map((atMinute) => ({ kind: "daily", atHour: 0, atMinute, repeatEveryMinutes: 60 }));
|
|
133
218
|
case "hour":
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
219
|
+
return [
|
|
220
|
+
{
|
|
221
|
+
kind: "daily",
|
|
222
|
+
atHour: 0,
|
|
223
|
+
atMinute: trigger.atMinute,
|
|
224
|
+
repeatEveryMinutes: trigger.everyHours * 60,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
case "hourValues":
|
|
228
|
+
return trigger.hours.map((atHour) => ({ kind: "daily", atHour, atMinute: trigger.atMinute }));
|
|
141
229
|
case "daily":
|
|
142
|
-
return
|
|
143
|
-
|
|
230
|
+
return [{ kind: "daily", atHour: trigger.atHour, atMinute: trigger.atMinute }];
|
|
231
|
+
case "weekly":
|
|
232
|
+
return [trigger];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function renderNativeTrigger(trigger, startBoundary) {
|
|
236
|
+
if (trigger.kind === "daily") {
|
|
237
|
+
const repetition = trigger.repeatEveryMinutes === undefined
|
|
238
|
+
? ""
|
|
239
|
+
: ` <Repetition>
|
|
240
|
+
<Interval>${formatRepetitionInterval(trigger.repeatEveryMinutes)}</Interval>
|
|
241
|
+
<Duration>${formatRepetitionDuration(trigger.repeatEveryMinutes)}</Duration>
|
|
242
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
243
|
+
</Repetition>
|
|
244
|
+
`;
|
|
245
|
+
return ` <CalendarTrigger>
|
|
246
|
+
${repetition} <StartBoundary>${startBoundary}</StartBoundary>
|
|
144
247
|
<Enabled>true</Enabled>
|
|
145
248
|
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
|
146
249
|
</CalendarTrigger>`;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<StartBoundary>${
|
|
250
|
+
}
|
|
251
|
+
const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
252
|
+
const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
|
|
253
|
+
return ` <CalendarTrigger>
|
|
254
|
+
<StartBoundary>${startBoundary}</StartBoundary>
|
|
152
255
|
<Enabled>true</Enabled>
|
|
153
256
|
<ScheduleByWeek>
|
|
154
257
|
<DaysOfWeek>
|
|
@@ -157,16 +260,42 @@ ${days}
|
|
|
157
260
|
<WeeksInterval>1</WeeksInterval>
|
|
158
261
|
</ScheduleByWeek>
|
|
159
262
|
</CalendarTrigger>`;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
263
|
}
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
264
|
+
function formatRepetitionInterval(minutes) {
|
|
265
|
+
return formatMinuteDuration(minutes);
|
|
266
|
+
}
|
|
267
|
+
function formatRepetitionDuration(intervalMinutes) {
|
|
268
|
+
return formatMinuteDuration(24 * 60 - intervalMinutes);
|
|
269
|
+
}
|
|
270
|
+
function formatMinuteDuration(minutes) {
|
|
271
|
+
const hours = Math.floor(minutes / 60);
|
|
272
|
+
const remainingMinutes = minutes % 60;
|
|
273
|
+
const hourPart = hours > 0 ? `${hours}H` : "";
|
|
274
|
+
const minutePart = remainingMinutes > 0 ? `${remainingMinutes}M` : "";
|
|
275
|
+
return `PT${hourPart}${minutePart}`;
|
|
276
|
+
}
|
|
277
|
+
function nextStartBoundary(trigger, now) {
|
|
278
|
+
const boundary = new Date(now.getTime());
|
|
279
|
+
switch (trigger.kind) {
|
|
280
|
+
case "daily":
|
|
281
|
+
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
|
|
282
|
+
if (trigger.repeatEveryMinutes !== undefined) {
|
|
283
|
+
while (boundary.getTime() <= now.getTime()) {
|
|
284
|
+
boundary.setMinutes(boundary.getMinutes() + trigger.repeatEveryMinutes);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (boundary.getTime() <= now.getTime()) {
|
|
288
|
+
boundary.setDate(boundary.getDate() + 1);
|
|
289
|
+
}
|
|
290
|
+
return boundary;
|
|
291
|
+
case "weekly":
|
|
292
|
+
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
|
|
293
|
+
while (!trigger.daysOfWeek.includes(boundary.getDay()) || boundary.getTime() <= now.getTime()) {
|
|
294
|
+
boundary.setDate(boundary.getDate() + 1);
|
|
295
|
+
boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
|
|
296
|
+
}
|
|
297
|
+
return boundary;
|
|
298
|
+
}
|
|
170
299
|
}
|
|
171
300
|
function formatStartBoundary(d) {
|
|
172
301
|
// Local-time ISO-8601 (no zone suffix) — Task Scheduler interprets a
|
|
@@ -183,7 +312,250 @@ function formatStartBoundary(d) {
|
|
|
183
312
|
function quoteArg(s) {
|
|
184
313
|
if (/^[A-Za-z0-9_\-./@:%=+,\\]+$/.test(s))
|
|
185
314
|
return s;
|
|
186
|
-
|
|
315
|
+
// CommandLineToArgvW requires backslashes before quotes (including the
|
|
316
|
+
// closing quote) to be doubled so they survive as literal backslashes.
|
|
317
|
+
return `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
|
|
318
|
+
}
|
|
319
|
+
function quotePowerShell(value) {
|
|
320
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
321
|
+
}
|
|
322
|
+
function installedSignature(xml) {
|
|
323
|
+
return taskXmlSignature(xml);
|
|
324
|
+
}
|
|
325
|
+
function isMissingTaskResult(result) {
|
|
326
|
+
return /cannot find|not found/i.test(`${result.stderr ?? ""}\n${result.stdout ?? ""}`);
|
|
327
|
+
}
|
|
328
|
+
function taskXmlSignature(xml) {
|
|
329
|
+
try {
|
|
330
|
+
const document = parseXml(xml);
|
|
331
|
+
const task = findChild(document, "Task");
|
|
332
|
+
const triggers = findChild(task, "Triggers");
|
|
333
|
+
const principals = findChild(task, "Principals");
|
|
334
|
+
const settings = findChild(task, "Settings");
|
|
335
|
+
const actions = findChild(task, "Actions");
|
|
336
|
+
if (!triggers || !principals || !settings || !actions)
|
|
337
|
+
return undefined;
|
|
338
|
+
normalizeTriggerBoundaries(triggers);
|
|
339
|
+
normalizeNativeDefaults(triggers, principals, settings);
|
|
340
|
+
const enabledElement = findChild(settings, "Enabled");
|
|
341
|
+
const enabledValue = enabledElement ? elementText(enabledElement).toLowerCase() : undefined;
|
|
342
|
+
const enabled = enabledValue === undefined || enabledValue === "true" || enabledValue === "1";
|
|
343
|
+
// Enabled is represented explicitly in the signature suffix. Removing it
|
|
344
|
+
// here also treats an omitted Enabled element as its schema default, true.
|
|
345
|
+
settings.children = settings.children.filter((child) => typeof child === "string" || child.name.toLowerCase() !== "enabled");
|
|
346
|
+
const canonical = [triggers, principals, settings, actions].map(canonicalXmlElement).join("\n");
|
|
347
|
+
const fingerprint = createHash("sha256").update(canonical).digest("hex");
|
|
348
|
+
return signatureWithEnabled(`${SIGNATURE_PREFIX}${fingerprint}`, enabled);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function taskXmlEnabled(xml) {
|
|
355
|
+
try {
|
|
356
|
+
const document = parseXml(xml);
|
|
357
|
+
const settings = findChild(findChild(document, "Task"), "Settings");
|
|
358
|
+
if (!settings)
|
|
359
|
+
return undefined;
|
|
360
|
+
const enabledElement = findChild(settings, "Enabled");
|
|
361
|
+
if (!enabledElement)
|
|
362
|
+
return true;
|
|
363
|
+
const enabled = elementText(enabledElement).toLowerCase();
|
|
364
|
+
if (enabled === "true" || enabled === "1")
|
|
365
|
+
return true;
|
|
366
|
+
if (enabled === "false" || enabled === "0")
|
|
367
|
+
return false;
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function parseXml(xml) {
|
|
375
|
+
const document = { name: "#document", attributes: {}, children: [] };
|
|
376
|
+
const stack = [document];
|
|
377
|
+
const tokens = xml.match(/<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<[^>]+>|[^<]+/g) ?? [];
|
|
378
|
+
for (const token of tokens) {
|
|
379
|
+
if (token.startsWith("<!--") || token.startsWith("<?"))
|
|
380
|
+
continue;
|
|
381
|
+
if (token.startsWith("</")) {
|
|
382
|
+
if (stack.length === 1)
|
|
383
|
+
throw new Error("Unexpected XML closing tag.");
|
|
384
|
+
const closingName = localXmlName(token.slice(2, -1).trim());
|
|
385
|
+
const current = stack.pop();
|
|
386
|
+
if (current?.name !== closingName)
|
|
387
|
+
throw new Error("Mismatched XML closing tag.");
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (token.startsWith("<")) {
|
|
391
|
+
const selfClosing = /\/\s*>$/.test(token);
|
|
392
|
+
const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
|
|
393
|
+
if (!match)
|
|
394
|
+
throw new Error("Invalid XML opening tag.");
|
|
395
|
+
const element = {
|
|
396
|
+
name: localXmlName(match[1]),
|
|
397
|
+
attributes: parseXmlAttributes(match[2]),
|
|
398
|
+
children: [],
|
|
399
|
+
};
|
|
400
|
+
stack[stack.length - 1].children.push(element);
|
|
401
|
+
if (!selfClosing)
|
|
402
|
+
stack.push(element);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const text = decodeXml(token.trim());
|
|
406
|
+
if (text.length > 0)
|
|
407
|
+
stack[stack.length - 1].children.push(text);
|
|
408
|
+
}
|
|
409
|
+
if (stack.length !== 1)
|
|
410
|
+
throw new Error("Unclosed XML tag.");
|
|
411
|
+
return document;
|
|
412
|
+
}
|
|
413
|
+
function parseXmlAttributes(raw) {
|
|
414
|
+
const attributes = {};
|
|
415
|
+
const pattern = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
416
|
+
for (const match of raw.matchAll(pattern)) {
|
|
417
|
+
const name = localXmlName(match[1]);
|
|
418
|
+
if (match[1] === "xmlns" || match[1].startsWith("xmlns:"))
|
|
419
|
+
continue;
|
|
420
|
+
attributes[name] = decodeXml(match[2] ?? match[3] ?? "");
|
|
421
|
+
}
|
|
422
|
+
return attributes;
|
|
423
|
+
}
|
|
424
|
+
function localXmlName(name) {
|
|
425
|
+
return name.slice(name.lastIndexOf(":") + 1);
|
|
426
|
+
}
|
|
427
|
+
function decodeXml(value) {
|
|
428
|
+
return value.replace(/&(?:#(\d+)|#x([\da-f]+)|amp|lt|gt|quot|apos);/gi, (entity, decimal, hex) => {
|
|
429
|
+
if (decimal !== undefined)
|
|
430
|
+
return String.fromCodePoint(Number(decimal));
|
|
431
|
+
if (hex !== undefined)
|
|
432
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
433
|
+
switch (entity.toLowerCase()) {
|
|
434
|
+
case "&":
|
|
435
|
+
return "&";
|
|
436
|
+
case "<":
|
|
437
|
+
return "<";
|
|
438
|
+
case ">":
|
|
439
|
+
return ">";
|
|
440
|
+
case """:
|
|
441
|
+
return '"';
|
|
442
|
+
case "'":
|
|
443
|
+
return "'";
|
|
444
|
+
default:
|
|
445
|
+
return entity;
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function findChild(parent, name) {
|
|
450
|
+
return parent?.children.find((child) => typeof child !== "string" && child.name.toLowerCase() === name.toLowerCase());
|
|
451
|
+
}
|
|
452
|
+
function elementText(element) {
|
|
453
|
+
return element.children.filter((child) => typeof child === "string").join("");
|
|
454
|
+
}
|
|
455
|
+
function normalizeTriggerBoundaries(triggers) {
|
|
456
|
+
for (const trigger of triggers.children) {
|
|
457
|
+
if (typeof trigger === "string")
|
|
458
|
+
continue;
|
|
459
|
+
const boundary = findChild(trigger, "StartBoundary");
|
|
460
|
+
if (!boundary)
|
|
461
|
+
continue;
|
|
462
|
+
const time = elementText(boundary).match(/T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?/);
|
|
463
|
+
if (!time)
|
|
464
|
+
continue;
|
|
465
|
+
const secondsSinceMidnight = Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3]);
|
|
466
|
+
const interval = findChild(findChild(trigger, "Repetition"), "Interval");
|
|
467
|
+
const intervalSeconds = interval ? parseIsoDurationSeconds(elementText(interval)) : undefined;
|
|
468
|
+
boundary.children = [
|
|
469
|
+
intervalSeconds === undefined
|
|
470
|
+
? `dynamic-date;time=${time[1]}:${time[2]}:${time[3]}`
|
|
471
|
+
: `dynamic-cycle;phase-seconds=${secondsSinceMidnight % intervalSeconds}`,
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const MATERIALIZED_SETTING_DEFAULTS = {
|
|
476
|
+
allowstartondemand: "true",
|
|
477
|
+
allowhardterminate: "true",
|
|
478
|
+
startwhenavailable: "false",
|
|
479
|
+
runonlyifnetworkavailable: "false",
|
|
480
|
+
hidden: "false",
|
|
481
|
+
runonlyifidle: "false",
|
|
482
|
+
waketorun: "false",
|
|
483
|
+
executiontimelimit: "PT72H",
|
|
484
|
+
priority: "7",
|
|
485
|
+
compatibility: "Vista",
|
|
486
|
+
useunifiedschedulingengine: "true",
|
|
487
|
+
disallowstartonremoteappsession: "false",
|
|
488
|
+
volatile: "false",
|
|
489
|
+
};
|
|
490
|
+
const MATERIALIZED_IDLE_DEFAULTS = {
|
|
491
|
+
duration: "PT10M",
|
|
492
|
+
waittimeout: "PT1H",
|
|
493
|
+
stoponidleend: "true",
|
|
494
|
+
restartonidle: "false",
|
|
495
|
+
};
|
|
496
|
+
function normalizeNativeDefaults(triggers, principals, settings) {
|
|
497
|
+
for (const principal of elementChildren(principals)) {
|
|
498
|
+
principal.children = principal.children.filter((child) => {
|
|
499
|
+
if (typeof child === "string")
|
|
500
|
+
return true;
|
|
501
|
+
return child.name.toLowerCase() !== "runlevel" || elementText(child).toLowerCase() !== "leastprivilege";
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
settings.children = settings.children.filter((child) => {
|
|
505
|
+
if (typeof child === "string")
|
|
506
|
+
return true;
|
|
507
|
+
const name = child.name.toLowerCase();
|
|
508
|
+
if (name === "idlesettings")
|
|
509
|
+
return !containsOnlyDefaults(child, MATERIALIZED_IDLE_DEFAULTS);
|
|
510
|
+
const defaultValue = MATERIALIZED_SETTING_DEFAULTS[name];
|
|
511
|
+
return defaultValue === undefined || elementText(child) !== defaultValue;
|
|
512
|
+
});
|
|
513
|
+
for (const trigger of elementChildren(triggers)) {
|
|
514
|
+
trigger.children = trigger.children.filter((child) => {
|
|
515
|
+
if (typeof child === "string")
|
|
516
|
+
return true;
|
|
517
|
+
const name = child.name.toLowerCase();
|
|
518
|
+
if (name === "enabled")
|
|
519
|
+
return elementText(child).toLowerCase() !== "true";
|
|
520
|
+
return name !== "executiontimelimit" || elementText(child) !== "PT72H";
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function resolveCurrentUserSid(exec) {
|
|
525
|
+
const result = exec.run(["whoami", "/user", "/fo", "csv", "/nh"]);
|
|
526
|
+
if (result.status !== 0) {
|
|
527
|
+
throw new ConfigError(`whoami /user failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
528
|
+
}
|
|
529
|
+
const match = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.match(/\bS-\d+(?:-\d+){2,}\b/i);
|
|
530
|
+
if (!match) {
|
|
531
|
+
throw new ConfigError("whoami /user returned no Windows user SID.", "INVALID_CONFIG_FILE");
|
|
532
|
+
}
|
|
533
|
+
return `S${match[0].slice(1)}`;
|
|
534
|
+
}
|
|
535
|
+
function elementChildren(element) {
|
|
536
|
+
return element.children.filter((child) => typeof child !== "string");
|
|
537
|
+
}
|
|
538
|
+
function containsOnlyDefaults(element, defaults) {
|
|
539
|
+
const children = elementChildren(element);
|
|
540
|
+
return (children.length > 0 &&
|
|
541
|
+
children.length === element.children.length &&
|
|
542
|
+
children.every((child) => defaults[child.name.toLowerCase()] === elementText(child)));
|
|
543
|
+
}
|
|
544
|
+
function parseIsoDurationSeconds(value) {
|
|
545
|
+
const match = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
|
|
546
|
+
if (!match)
|
|
547
|
+
return undefined;
|
|
548
|
+
const seconds = Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0);
|
|
549
|
+
return seconds > 0 ? seconds : undefined;
|
|
550
|
+
}
|
|
551
|
+
function canonicalXmlElement(element) {
|
|
552
|
+
const attributes = Object.entries(element.attributes).sort(([a], [b]) => a.localeCompare(b));
|
|
553
|
+
const children = element.children.map((child) => typeof child === "string" ? ["text", child] : ["element", canonicalXmlElement(child)]);
|
|
554
|
+
children.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
555
|
+
return JSON.stringify([element.name, attributes, children]);
|
|
556
|
+
}
|
|
557
|
+
function signatureWithEnabled(signature, enabled) {
|
|
558
|
+
return `${signature}|enabled=${enabled ? "true" : "false"}`;
|
|
187
559
|
}
|
|
188
560
|
function defaultSchtasksExec() {
|
|
189
561
|
return nodeExec();
|
|
@@ -191,6 +563,9 @@ function defaultSchtasksExec() {
|
|
|
191
563
|
function defaultSchtasksFs() {
|
|
192
564
|
return {
|
|
193
565
|
...nodeFs(),
|
|
566
|
+
writeFile(file, content) {
|
|
567
|
+
fs.writeFileSync(file, `\uFEFF${content}`, { encoding: "utf16le" });
|
|
568
|
+
},
|
|
194
569
|
removeFile(file) {
|
|
195
570
|
try {
|
|
196
571
|
fs.rmSync(file, { force: true });
|