@oar-sh/oar 0.9.13 → 0.9.15
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/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- package/server/uploads/image_input.md +0 -540
|
@@ -3,16 +3,24 @@ import path from 'path';
|
|
|
3
3
|
import { execFile, spawn } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
6
|
+
import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layout.mjs';
|
|
6
7
|
import {
|
|
7
8
|
describeExecError,
|
|
8
9
|
describeSpawnEnvironment,
|
|
10
|
+
windowsSystemRoot,
|
|
9
11
|
windowsSystemToolPath,
|
|
10
12
|
} from '../../shared/windows-system-tools.mjs';
|
|
11
13
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
autostartWaitLines,
|
|
15
|
+
backupPathFor,
|
|
16
|
+
buildLauncherFile,
|
|
17
|
+
createWindowsAutostartService,
|
|
14
18
|
escapeBatchValue,
|
|
15
|
-
|
|
19
|
+
launcherNodeLine,
|
|
20
|
+
launcherScriptsMatch,
|
|
21
|
+
otherInstallOf,
|
|
22
|
+
readLauncherNodePath,
|
|
23
|
+
stateDirLines,
|
|
16
24
|
} from './windows-autostart-service.mjs';
|
|
17
25
|
|
|
18
26
|
/**
|
|
@@ -48,10 +56,14 @@ export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
|
|
|
48
56
|
// Where a regenerated launcher keeps the one it replaced, when they differ.
|
|
49
57
|
export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
|
|
50
58
|
const ELEVATION_TIMEOUT_MS = 120_000;
|
|
51
|
-
|
|
59
|
+
// Next to the launcher: a wait for TEMP and the data drive that ran out (see
|
|
60
|
+
// autostartWaitLines; AUTOSTART_WAIT_SECONDS is the limit).
|
|
52
61
|
export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
|
|
53
62
|
// Each schtasks query; the settings page waits on two of them.
|
|
54
63
|
const QUERY_TIMEOUT_MS = 6_000;
|
|
64
|
+
// The PowerShell export a task with non-ASCII text is read again with
|
|
65
|
+
// (PowerShell and its ScheduledTasks module start first).
|
|
66
|
+
const EXPORT_TIMEOUT_MS = 20_000;
|
|
55
67
|
|
|
56
68
|
function normalizeText(value) {
|
|
57
69
|
return String(value == null ? '' : value).trim();
|
|
@@ -69,6 +81,8 @@ function escapeXml(value) {
|
|
|
69
81
|
/**
|
|
70
82
|
* The launcher the task executes. Same shape as the sign-in .cmd minus the
|
|
71
83
|
* `title` line: a boot task runs in session 0 where no console is ever shown.
|
|
84
|
+
* Like it, the launcher pins the config and the data and log folders `oar
|
|
85
|
+
* start` uses, so both start the same relay.
|
|
72
86
|
*
|
|
73
87
|
* Node's folder goes at the end of PATH. The relay itself runs on the
|
|
74
88
|
* absolute path below, but a boot task's PATH can lack the user's Node (a
|
|
@@ -76,65 +90,142 @@ function escapeXml(value) {
|
|
|
76
90
|
* and the CLIs and commands the workers start may call a bare `node`. At the
|
|
77
91
|
* end it only matters when PATH has no Node of its own.
|
|
78
92
|
*
|
|
79
|
-
* Before Node starts
|
|
80
|
-
*
|
|
81
|
-
* only after the task has fired, and Node, the workers and their CLIs then
|
|
82
|
-
* fail creating their temp files. It costs nothing when the folder is there,
|
|
83
|
-
* is skipped when TEMP is not set, and a wait that runs out is logged to
|
|
84
|
-
* BOOT_LAUNCHER_LOG_FILENAME next to the launcher before Node starts anyway.
|
|
93
|
+
* Before Node starts it waits for %TEMP% and the data drive (autostartWaitLines),
|
|
94
|
+
* logging a wait that ran out to BOOT_LAUNCHER_LOG_FILENAME beside it.
|
|
85
95
|
*/
|
|
96
|
+
const BOOT_LAUNCHER_HEADER = [
|
|
97
|
+
'@echo off',
|
|
98
|
+
'rem Generated by OAR settings (system-startup autostart). Regenerated on',
|
|
99
|
+
'rem every settings change - do not edit by hand.',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl }) {
|
|
103
|
+
if (!normalizeText(packageRoot)) {
|
|
104
|
+
throw new Error('Windows boot autostart requires the package root.');
|
|
105
|
+
}
|
|
106
|
+
if (!normalizeText(nodePath)) {
|
|
107
|
+
throw new Error('Windows boot autostart requires the Node.js executable path.');
|
|
108
|
+
}
|
|
109
|
+
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
110
|
+
return {
|
|
111
|
+
serverPath: pathImpl.join(resolvedPackageRoot, 'server', 'server.js'),
|
|
112
|
+
resolvedNodePath: pathImpl.resolve(nodePath),
|
|
113
|
+
cwdTarget: normalizeText(workingDirectory)
|
|
114
|
+
? pathImpl.resolve(String(workingDirectory))
|
|
115
|
+
: resolvedPackageRoot,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function configLines(configPath, pathImpl) {
|
|
120
|
+
return normalizeText(configPath)
|
|
121
|
+
? [`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`]
|
|
122
|
+
: [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 0.9.11 to 0.9.13's wait (%TEMP% only, 120 s), verbatim, so a refresh knows it.
|
|
126
|
+
const EARLIER_BOOT_TEMP_WAIT_LINES = [
|
|
127
|
+
'if "%TEMP%"=="" goto oar_start',
|
|
128
|
+
'set /a OAR_TEMP_WAIT=0',
|
|
129
|
+
':oar_wait_temp',
|
|
130
|
+
'if exist "%TEMP%\\" goto oar_start',
|
|
131
|
+
'if %OAR_TEMP_WAIT% geq 120 goto oar_temp_missing',
|
|
132
|
+
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
133
|
+
'set /a OAR_TEMP_WAIT+=1',
|
|
134
|
+
'goto oar_wait_temp',
|
|
135
|
+
':oar_temp_missing',
|
|
136
|
+
`echo [%DATE% %TIME%] "%TEMP%" still missing after 120 s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
|
|
137
|
+
':oar_start',
|
|
138
|
+
'set "OAR_TEMP_WAIT="',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// The boot launcher's lines after the padding, with the wait and the Node
|
|
142
|
+
// line's ending given (see launcherNodeLine).
|
|
143
|
+
function bootBodyLines({ resolvedNodePath, serverPath, cwdTarget, configPath, dataDir, logDir, pathImpl, waitLines, nodeLineOptions = {} }) {
|
|
144
|
+
return [
|
|
145
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
146
|
+
...configLines(configPath, pathImpl),
|
|
147
|
+
...stateDirLines({ dataDir, logDir, pathImpl }),
|
|
148
|
+
...waitLines,
|
|
149
|
+
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
150
|
+
launcherNodeLine(resolvedNodePath, serverPath, nodeLineOptions),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
86
154
|
export function buildBootLauncherScript({
|
|
87
155
|
packageRoot,
|
|
88
156
|
nodePath = process.execPath,
|
|
89
157
|
configPath = '',
|
|
158
|
+
dataDir = '',
|
|
159
|
+
logDir = '',
|
|
90
160
|
// Where the relay process lives, NOT where its code lives: on Windows a
|
|
91
161
|
// process pins its cwd, and self-update must be able to replace the package
|
|
92
162
|
// directory. Callers pass the OAR state root; the package-root default only
|
|
93
163
|
// serves builders that predate self-update.
|
|
94
164
|
workingDirectory = '',
|
|
165
|
+
// Size of the file this one replaces (see buildLauncherFile).
|
|
166
|
+
coverBytes = 0,
|
|
95
167
|
pathImpl = path,
|
|
96
168
|
} = {}) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
169
|
+
const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
|
|
170
|
+
return buildLauncherFile({
|
|
171
|
+
headerLines: BOOT_LAUNCHER_HEADER,
|
|
172
|
+
bodyLines: bootBodyLines({
|
|
173
|
+
resolvedNodePath,
|
|
174
|
+
serverPath,
|
|
175
|
+
cwdTarget,
|
|
176
|
+
configPath,
|
|
177
|
+
dataDir,
|
|
178
|
+
logDir,
|
|
179
|
+
pathImpl,
|
|
180
|
+
waitLines: autostartWaitLines({ dataDir, logTarget: `"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`, pathImpl }),
|
|
181
|
+
}),
|
|
182
|
+
coverBytes,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The launchers earlier versions generated, newest first: the padded one
|
|
188
|
+
* before 0.9.14 shipped (120 s TEMP wait, `& exit /b`; needs `dataDir` and
|
|
189
|
+
* `logDir`), 0.9.11 to 0.9.13, then 0.9.2 to 0.9.10.
|
|
190
|
+
*/
|
|
191
|
+
export function buildEarlierBootLauncherScripts({
|
|
192
|
+
packageRoot,
|
|
193
|
+
nodePath,
|
|
194
|
+
configPath = '',
|
|
195
|
+
dataDir = '',
|
|
196
|
+
logDir = '',
|
|
197
|
+
workingDirectory = '',
|
|
198
|
+
pathImpl = path,
|
|
199
|
+
} = {}) {
|
|
200
|
+
const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
|
|
201
|
+
const nodeLine = `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`;
|
|
202
|
+
const cdLine = `cd /d "${escapeBatchValue(cwdTarget)}"`;
|
|
203
|
+
return [
|
|
204
|
+
buildLauncherFile({
|
|
205
|
+
headerLines: BOOT_LAUNCHER_HEADER,
|
|
206
|
+
bodyLines: bootBodyLines({
|
|
207
|
+
resolvedNodePath,
|
|
208
|
+
serverPath,
|
|
209
|
+
cwdTarget,
|
|
210
|
+
configPath,
|
|
211
|
+
dataDir,
|
|
212
|
+
logDir,
|
|
213
|
+
pathImpl,
|
|
214
|
+
waitLines: EARLIER_BOOT_TEMP_WAIT_LINES,
|
|
215
|
+
nodeLineOptions: { ending: 'exit /b' },
|
|
216
|
+
}),
|
|
217
|
+
}),
|
|
218
|
+
[
|
|
219
|
+
...BOOT_LAUNCHER_HEADER,
|
|
220
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
221
|
+
...configLines(configPath, pathImpl),
|
|
222
|
+
...EARLIER_BOOT_TEMP_WAIT_LINES,
|
|
223
|
+
cdLine,
|
|
224
|
+
nodeLine,
|
|
225
|
+
'',
|
|
226
|
+
].join('\r\n'),
|
|
227
|
+
[...BOOT_LAUNCHER_HEADER, ...configLines(configPath, pathImpl), cdLine, nodeLine, ''].join('\r\n'),
|
|
111
228
|
];
|
|
112
|
-
if (normalizeText(configPath)) {
|
|
113
|
-
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
114
|
-
}
|
|
115
|
-
const cwdTarget = normalizeText(workingDirectory)
|
|
116
|
-
? pathImpl.resolve(String(workingDirectory))
|
|
117
|
-
: resolvedPackageRoot;
|
|
118
|
-
lines.push(
|
|
119
|
-
// About one second a round: ping waits a second between its two echoes.
|
|
120
|
-
// By full path, since the PATH of an early boot may not lead to System32.
|
|
121
|
-
'if "%TEMP%"=="" goto oar_start',
|
|
122
|
-
'set /a OAR_TEMP_WAIT=0',
|
|
123
|
-
':oar_wait_temp',
|
|
124
|
-
'if exist "%TEMP%\\" goto oar_start',
|
|
125
|
-
`if %OAR_TEMP_WAIT% geq ${BOOT_TEMP_WAIT_SECONDS} goto oar_temp_missing`,
|
|
126
|
-
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
127
|
-
'set /a OAR_TEMP_WAIT+=1',
|
|
128
|
-
'goto oar_wait_temp',
|
|
129
|
-
':oar_temp_missing',
|
|
130
|
-
`echo [%DATE% %TIME%] "%TEMP%" still missing after ${BOOT_TEMP_WAIT_SECONDS} s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
|
|
131
|
-
':oar_start',
|
|
132
|
-
'set "OAR_TEMP_WAIT="',
|
|
133
|
-
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
134
|
-
`"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
|
|
135
|
-
'',
|
|
136
|
-
);
|
|
137
|
-
return lines.join('\r\n');
|
|
138
229
|
}
|
|
139
230
|
|
|
140
231
|
function unescapeXml(value) {
|
|
@@ -250,39 +341,139 @@ function samePath(a, b) {
|
|
|
250
341
|
return normalize(a) === normalize(b);
|
|
251
342
|
}
|
|
252
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Elements, attributes and text of a Task Scheduler export, as a tree of
|
|
346
|
+
* `{ name, attrs, children, text }`; no XML parser dependency. Throws on
|
|
347
|
+
* anything it cannot read (callers take that as hand-made).
|
|
348
|
+
*/
|
|
349
|
+
export function parseXmlTree(xmlText) {
|
|
350
|
+
const root = { name: '#document', attrs: {}, children: [], text: '' };
|
|
351
|
+
const stack = [root];
|
|
352
|
+
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)|(<)/g;
|
|
353
|
+
for (const match of String(xmlText == null ? '' : xmlText).matchAll(token)) {
|
|
354
|
+
const [, closing, name, attrText, selfClosing, text, stray] = match;
|
|
355
|
+
const top = stack.at(-1);
|
|
356
|
+
if (stray) throw new Error('unreadable task XML');
|
|
357
|
+
if (text !== undefined) {
|
|
358
|
+
top.text += unescapeXml(text);
|
|
359
|
+
} else if (closing) {
|
|
360
|
+
if (top.name !== name || stack.length === 1) throw new Error('unbalanced task XML');
|
|
361
|
+
stack.pop();
|
|
362
|
+
} else if (name) {
|
|
363
|
+
const attrs = {};
|
|
364
|
+
for (const [, key, quoted] of String(attrText || '').matchAll(/([\w.:-]+)\s*=\s*("[^"]*"|'[^']*')/g)) {
|
|
365
|
+
attrs[key] = unescapeXml(quoted.slice(1, -1));
|
|
366
|
+
}
|
|
367
|
+
const element = { name, attrs, children: [], text: '' };
|
|
368
|
+
top.children.push(element);
|
|
369
|
+
if (!selfClosing) stack.push(element);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (stack.length !== 1) throw new Error('unbalanced task XML');
|
|
373
|
+
return root;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The settings buildBootTaskXml writes, and those Task Scheduler adds with
|
|
377
|
+
// their default values on export. Any other setting or value is a change.
|
|
378
|
+
const TASK_SETTINGS = {
|
|
379
|
+
DisallowStartIfOnBatteries: 'false',
|
|
380
|
+
StopIfGoingOnBatteries: 'false',
|
|
381
|
+
ExecutionTimeLimit: 'PT0S',
|
|
382
|
+
StartWhenAvailable: 'true',
|
|
383
|
+
RestartOnFailure: { Count: '5', Interval: 'PT1M' },
|
|
384
|
+
};
|
|
385
|
+
const TASK_SETTING_DEFAULTS = {
|
|
386
|
+
MultipleInstancesPolicy: 'IgnoreNew',
|
|
387
|
+
UseUnifiedSchedulingEngine: 'true',
|
|
388
|
+
AllowHardTerminate: 'true',
|
|
389
|
+
RunOnlyIfNetworkAvailable: 'false',
|
|
390
|
+
AllowStartOnDemand: 'true',
|
|
391
|
+
Enabled: 'true',
|
|
392
|
+
Hidden: 'false',
|
|
393
|
+
RunOnlyIfIdle: 'false',
|
|
394
|
+
DisallowStartOnRemoteAppSession: 'false',
|
|
395
|
+
WakeToRun: 'false',
|
|
396
|
+
Priority: '7',
|
|
397
|
+
IdleSettings: { StopOnIdleEnd: 'true', RestartOnIdle: 'false', Duration: 'PT10M', WaitTimeout: 'PT1H' },
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Children of `element` are exactly the named ones with these values
|
|
401
|
+
// (`optional` ones may be missing).
|
|
402
|
+
function childrenMatch(element, required, optional = {}) {
|
|
403
|
+
const seen = new Set();
|
|
404
|
+
for (const child of element?.children || []) {
|
|
405
|
+
if (seen.has(child.name)) return false;
|
|
406
|
+
seen.add(child.name);
|
|
407
|
+
const wanted = required[child.name] ?? optional[child.name];
|
|
408
|
+
if (wanted === undefined) return false;
|
|
409
|
+
if (typeof wanted === 'object' ? !childrenMatch(child, {}, wanted) || child.text.trim() : !sameText(child.text, wanted)) return false;
|
|
410
|
+
}
|
|
411
|
+
return Object.keys(required).every((name) => seen.has(name)
|
|
412
|
+
&& (typeof required[name] !== 'object' || childrenMatch(element.children.find((child) => child.name === name), required[name])));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sameUser(userId, user) {
|
|
416
|
+
const id = normalizeText(userId).toLowerCase();
|
|
417
|
+
if (!id || !user) return false;
|
|
418
|
+
const name = normalizeText(user.name).toLowerCase();
|
|
419
|
+
return id === normalizeText(user.sid).toLowerCase() || (name && (id === name || id === name.split('\\').pop()));
|
|
420
|
+
}
|
|
421
|
+
|
|
253
422
|
/**
|
|
254
423
|
* How a task that runs our launcher differs from the one buildBootTaskXml
|
|
255
|
-
* registers, as short names (empty when it does not)
|
|
256
|
-
*
|
|
257
|
-
*
|
|
424
|
+
* registers, as short names (empty when it does not): only a task that is
|
|
425
|
+
* that one in every field that matters is OAR's own. Task Scheduler's own
|
|
426
|
+
* additions on export are no change (RegistrationInfo, the defaults in
|
|
427
|
+
* TASK_SETTING_DEFAULTS, `Enabled` on the trigger); anything else is: the
|
|
428
|
+
* account (`user`, when given: `{ sid, name }`, or null when it is not known),
|
|
429
|
+
* a run level above least privilege, a second action or trigger, any other
|
|
430
|
+
* setting. XML it cannot read is `unreadable`.
|
|
258
431
|
*/
|
|
259
|
-
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
|
|
260
|
-
|
|
432
|
+
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory, user } = {}) {
|
|
433
|
+
let task;
|
|
434
|
+
try {
|
|
435
|
+
task = parseXmlTree(xmlText).children.find((element) => element.name === 'Task');
|
|
436
|
+
} catch {}
|
|
437
|
+
if (!task) return ['unreadable'];
|
|
261
438
|
const differences = [];
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
439
|
+
const section = (name) => task.children.filter((element) => element.name === name);
|
|
440
|
+
if (task.children.some((element) => !['RegistrationInfo', 'Principals', 'Settings', 'Triggers', 'Actions'].includes(element.name))
|
|
441
|
+
|| ['Principals', 'Settings', 'Triggers', 'Actions'].some((name) => section(name).length !== 1)) {
|
|
442
|
+
differences.push('other');
|
|
443
|
+
}
|
|
444
|
+
const [principal, ...morePrincipals] = section('Principals')[0]?.children || [];
|
|
445
|
+
const field = (element, name) => element?.children.find((child) => child.name === name)?.text ?? null;
|
|
446
|
+
if (user !== undefined && (!sameUser(field(principal, 'UserId'), user) || morePrincipals.length)) differences.push('user');
|
|
447
|
+
if (!sameText(field(principal, 'LogonType'), 'S4U')) differences.push('logon-type');
|
|
448
|
+
if (field(principal, 'RunLevel') !== null && !sameText(field(principal, 'RunLevel'), 'LeastPrivilege')) differences.push('run-level');
|
|
449
|
+
if ((principal?.children || []).some((child) => !['UserId', 'LogonType', 'RunLevel'].includes(child.name))) differences.push('user');
|
|
450
|
+
|
|
451
|
+
const actions = section('Actions')[0]?.children || [];
|
|
452
|
+
const exec = actions[0];
|
|
453
|
+
if (actions.length !== 1 || exec.name !== 'Exec'
|
|
454
|
+
|| exec.children.some((child) => !['Command', 'Arguments', 'WorkingDirectory'].includes(child.name))
|
|
455
|
+
|| !sameText(field(exec, 'Command'), 'C:\\Windows\\System32\\cmd.exe')
|
|
456
|
+
|| normalizeText(field(exec, 'Arguments')).replace(/\s+/g, ' ').toLowerCase() !== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
|
|
265
457
|
differences.push('action');
|
|
266
458
|
}
|
|
267
|
-
if (normalizeText(workingDirectory) && !samePath(
|
|
459
|
+
if (normalizeText(workingDirectory) && !samePath(field(exec, 'WorkingDirectory'), workingDirectory)) {
|
|
268
460
|
differences.push('working-directory');
|
|
269
461
|
}
|
|
270
|
-
if (!sameText(parsed.logonType, 'S4U')) differences.push('logon-type');
|
|
271
|
-
if (!parsed.hasBootTrigger) differences.push('trigger');
|
|
272
|
-
else if (!sameText(parsed.bootDelay, 'PT30S')) differences.push('boot-delay');
|
|
273
|
-
return differences;
|
|
274
|
-
}
|
|
275
462
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
.
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
|
|
283
|
-
|
|
463
|
+
const triggers = section('Triggers')[0]?.children || [];
|
|
464
|
+
const boot = triggers[0];
|
|
465
|
+
if (triggers.length !== 1 || boot.name !== 'BootTrigger' || !childrenMatch(boot, {}, { Delay: field(boot, 'Delay') ?? '', Enabled: 'true' })) {
|
|
466
|
+
differences.push('trigger');
|
|
467
|
+
} else if (!sameText(field(boot, 'Delay'), 'PT30S')) {
|
|
468
|
+
differences.push('boot-delay');
|
|
469
|
+
}
|
|
470
|
+
if (!childrenMatch(section('Settings')[0], TASK_SETTINGS, TASK_SETTING_DEFAULTS)) differences.push('settings');
|
|
471
|
+
return [...new Set(differences)];
|
|
284
472
|
}
|
|
285
473
|
|
|
474
|
+
// Shared with the sign-in entry; still exported from here for its callers.
|
|
475
|
+
export { launcherScriptsMatch };
|
|
476
|
+
|
|
286
477
|
/**
|
|
287
478
|
* Ours or someone else's? A task counts as ours when its action runs our
|
|
288
479
|
* launcher path; anything else under our name is `foreign` and the UI offers
|
|
@@ -297,35 +488,150 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
|
|
|
297
488
|
return 'foreign';
|
|
298
489
|
}
|
|
299
490
|
|
|
491
|
+
/**
|
|
492
|
+
* A PowerShell string literal that keeps `value` as it is: single-quoted
|
|
493
|
+
* (no `$` or backtick expansion), each single quote doubled, typographic
|
|
494
|
+
* ones too (PowerShell ends a single-quoted string at those as well).
|
|
495
|
+
*/
|
|
496
|
+
export function psLiteral(value) {
|
|
497
|
+
return `'${String(value).replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The exit code of a sweep that failed (and put back what it had deleted).
|
|
501
|
+
export const SWEEP_FAILED_EXIT_CODE = 3;
|
|
502
|
+
// Windows' ERROR_CANCELLED: the administrator prompt was declined.
|
|
503
|
+
export const ELEVATION_DECLINED_EXIT_CODE = 1223;
|
|
504
|
+
|
|
300
505
|
/**
|
|
301
506
|
* The elevated sweep. Everything the admin token is actually needed for —
|
|
302
507
|
* and nothing else — in one script, so one UAC covers the whole transition
|
|
303
508
|
* out of any mixed state (legacy-named task, hand-made task, fresh install).
|
|
509
|
+
* Every path is a PowerShell literal (psLiteral): a space, an apostrophe,
|
|
510
|
+
* `&`, `%`, `$` or non-ASCII in the OAR folder are passed on as they are.
|
|
511
|
+
* When it fails (the create, or a task still there after its delete) it
|
|
512
|
+
* puts back each task it deleted from `restore` (`{ task, xml }`: the copies
|
|
513
|
+
* made before, which `schtasks /create /xml` takes back) and exits
|
|
514
|
+
* SWEEP_FAILED_EXIT_CODE.
|
|
304
515
|
*/
|
|
305
|
-
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
|
|
516
|
+
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks', includeLegacy = false, restore = [] } = {}) {
|
|
306
517
|
// PowerShell runs a quoted path only behind the call operator.
|
|
307
|
-
const schtasks = /[\\/]/.test(schtasksPath) ? `&
|
|
518
|
+
const schtasks = /[\\/]/.test(schtasksPath) ? `& ${psLiteral(schtasksPath)}` : schtasksPath;
|
|
519
|
+
const remove = (name) => `${schtasks} /delete /tn ${psLiteral(name)} /f 2>$null`;
|
|
520
|
+
const stillThere = (name) => [`${schtasks} /query /tn ${psLiteral(name)} *>$null`, 'if ($LASTEXITCODE -eq 0) { $failed = $true }'];
|
|
521
|
+
let steps;
|
|
308
522
|
if (action === 'enable') {
|
|
309
523
|
if (!normalizeText(xmlPath)) {
|
|
310
524
|
throw new Error('The enable sweep needs the task XML path.');
|
|
311
525
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
`${schtasks} /create /tn
|
|
316
|
-
'
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
'',
|
|
326
|
-
].join('\r\n');
|
|
526
|
+
steps = [
|
|
527
|
+
remove(LEGACY_BOOT_TASK_NAME),
|
|
528
|
+
remove(BOOT_TASK_NAME),
|
|
529
|
+
`${schtasks} /create /tn ${psLiteral(BOOT_TASK_NAME)} /xml ${psLiteral(xmlPath)}`,
|
|
530
|
+
'if ($LASTEXITCODE -ne 0) { $failed = $true }',
|
|
531
|
+
];
|
|
532
|
+
} else if (action === 'disable') {
|
|
533
|
+
// Our own task only: a pre-rebrand one was made by hand, and stays
|
|
534
|
+
// unless `includeLegacy` (--replace, a copy kept).
|
|
535
|
+
const names = [BOOT_TASK_NAME, ...(includeLegacy ? [LEGACY_BOOT_TASK_NAME] : [])];
|
|
536
|
+
steps = [...names.map(remove), ...names.flatMap(stillThere)];
|
|
537
|
+
} else {
|
|
538
|
+
throw new Error(`Unknown elevation action: ${JSON.stringify(action)}`);
|
|
327
539
|
}
|
|
328
|
-
|
|
540
|
+
const putBack = restore.flatMap(({ task, xml }) => [
|
|
541
|
+
` ${schtasks} /query /tn ${psLiteral(task)} *>$null`,
|
|
542
|
+
` if ($LASTEXITCODE -ne 0) { ${schtasks} /create /tn ${psLiteral(task)} /xml ${psLiteral(xml)} /f 2>$null }`,
|
|
543
|
+
]);
|
|
544
|
+
return [
|
|
545
|
+
'$failed = $false',
|
|
546
|
+
...steps,
|
|
547
|
+
'if ($failed) {',
|
|
548
|
+
...putBack,
|
|
549
|
+
` exit ${SWEEP_FAILED_EXIT_CODE}`,
|
|
550
|
+
'}',
|
|
551
|
+
'exit 0',
|
|
552
|
+
'',
|
|
553
|
+
].join('\r\n');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The elevated PowerShell did not start (Start-Process failed otherwise
|
|
557
|
+
// than by the cancel): nothing elevated ran, as with a declined prompt.
|
|
558
|
+
export const ELEVATION_NOT_STARTED_EXIT_CODE = 2;
|
|
559
|
+
// It started, but its exit code cannot be told: the sweep may have run.
|
|
560
|
+
export const ELEVATION_UNKNOWN_EXIT_CODE = 4;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The command the unelevated PowerShell runs: Start-Process -Verb RunAs of
|
|
564
|
+
* a PowerShell (no profile) that runs `script` as an -EncodedCommand (UTF-16,
|
|
565
|
+
* so no path in it is quoted or re-encoded on the way). Its exit code is the
|
|
566
|
+
* sweep's only when the elevated PowerShell ran: a declined prompt is
|
|
567
|
+
* ELEVATION_DECLINED_EXIT_CODE, whether Start-Process says so with an
|
|
568
|
+
* error (stopped here, also when it is not a terminating one) or returns no
|
|
569
|
+
* process at all; never 0, which `exit $null` would give.
|
|
570
|
+
* ELEVATION_NOT_STARTED_EXIT_CODE: Start-Process failed otherwise.
|
|
571
|
+
* `verb: ''` (tests) runs it unelevated.
|
|
572
|
+
*/
|
|
573
|
+
export function buildElevationCommand({ powershellPath, script, verb = 'RunAs' } = {}) {
|
|
574
|
+
const encoded = Buffer.from(String(script), 'utf16le').toString('base64');
|
|
575
|
+
const declined = ELEVATION_DECLINED_EXIT_CODE;
|
|
576
|
+
return [
|
|
577
|
+
'$ErrorActionPreference = \'Stop\'; $p = $null;',
|
|
578
|
+
`try { $p = Start-Process -FilePath ${psLiteral(powershellPath)}${verb ? ` -Verb ${verb}` : ''} -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop`,
|
|
579
|
+
`-ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}' }`,
|
|
580
|
+
// 1223 is ERROR_CANCELLED; -2147023673 the same as an HRESULT.
|
|
581
|
+
`catch { $e = $_.Exception; while ($e) { if ($e.NativeErrorCode -eq ${declined} -or $e.HResult -eq -2147023673) { exit ${declined} }; $e = $e.InnerException }; exit ${ELEVATION_NOT_STARTED_EXIT_CODE} };`,
|
|
582
|
+
`if (-not $p) { exit ${declined} };`,
|
|
583
|
+
`if ($null -eq $p.ExitCode) { exit ${ELEVATION_UNKNOWN_EXIT_CODE} };`,
|
|
584
|
+
'exit $p.ExitCode',
|
|
585
|
+
].join(' ');
|
|
586
|
+
}
|
|
587
|
+
/** How a task is named where entries are listed (`handMade`, `oar autostart status`). */
|
|
588
|
+
export function bootTaskLabel(name) {
|
|
589
|
+
return `scheduled task \\${name}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The autostart mode out of the sign-in entries (`listEntries()` of the
|
|
594
|
+
* sign-in service) and the boot state (`getState()`), with every entry as
|
|
595
|
+
* `{ mode, path, generated, otherInstall }`; `unknown` when the boot task
|
|
596
|
+
* cannot be read. `oar autostart` and the web UI read it alike.
|
|
597
|
+
*/
|
|
598
|
+
export function describeWindowsAutostart({ signinEntries = [], boot = {} } = {}) {
|
|
599
|
+
const handMade = boot.handMade || [];
|
|
600
|
+
const listed = (entryPath) => handMade.some((entry) => entry.path === entryPath);
|
|
601
|
+
const entries = signinEntries.map((entry) => ({ ...entry, otherInstall: entry.otherInstall || null, mode: 'signin' }));
|
|
602
|
+
const own = bootTaskLabel(boot.taskName || BOOT_TASK_NAME);
|
|
603
|
+
if (boot.taskStatus === 'ready' && !listed(own)) entries.push({ mode: 'boot', path: own, generated: true, otherInstall: null });
|
|
604
|
+
if (boot.launcherPath && !listed(boot.launcherPath)) entries.push({ mode: 'boot', path: boot.launcherPath, generated: true, otherInstall: null });
|
|
605
|
+
entries.push(...handMade.map((entry) => ({ mode: 'boot', path: entry.path, generated: false, otherInstall: entry.otherInstall || null })));
|
|
606
|
+
let mode = entries.some((entry) => entry.mode === 'signin') ? 'signin' : 'off';
|
|
607
|
+
if (boot.taskStatus === 'ready') mode = 'boot';
|
|
608
|
+
if (boot.readError) mode = 'unknown';
|
|
609
|
+
return { mode, entries };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The hand-made entries that stop a switch to `target`: one of another mode
|
|
614
|
+
* would stay beside it, and a hand-made boot entry would be replaced by
|
|
615
|
+
* switching boot on. While there are any, nothing changes (unless --replace).
|
|
616
|
+
*/
|
|
617
|
+
export function switchBlockers({ mode, entries = [] } = {}, target) {
|
|
618
|
+
return entries.filter((entry) => !entry.generated && (entry.mode !== target || (target === 'boot' && mode !== 'boot')));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function describeHandMade(entry) {
|
|
622
|
+
return `${entry.path} (hand-made${entry.otherInstall ? `: it starts the OAR install in ${entry.otherInstall}` : ''})`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* What `oar autostart` prints, and the web UI shows, when hand-made entries
|
|
627
|
+
* stop a switch: what to do first (the UI shortens a long error), then each
|
|
628
|
+
* entry.
|
|
629
|
+
*/
|
|
630
|
+
export function handMadeLines(entries, target) {
|
|
631
|
+
return [
|
|
632
|
+
`Nothing changed: OAR does not delete or overwrite an autostart entry it did not make. Remove it yourself, or replace it keeping a copy: oar autostart ${target} --replace.`,
|
|
633
|
+
...entries.map((entry) => `Left as it is: ${describeHandMade(entry)}.`),
|
|
634
|
+
];
|
|
329
635
|
}
|
|
330
636
|
|
|
331
637
|
/** `whoami /user /fo csv` → the current user's SID. */
|
|
@@ -334,11 +640,30 @@ export function parseSidFromWhoami(stdout) {
|
|
|
334
640
|
return match ? match[1] : null;
|
|
335
641
|
}
|
|
336
642
|
|
|
643
|
+
/** `whoami /user /fo csv` → `{ name, sid }` of the current user, or null. */
|
|
644
|
+
export function parseUserFromWhoami(stdout) {
|
|
645
|
+
const match = String(stdout || '').match(/"([^"\r\n]+)","(S-1-[\d-]+)"/);
|
|
646
|
+
return match ? { name: match[1], sid: match[2] } : null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The command that exports a task's XML with a known encoding: Windows
|
|
651
|
+
* PowerShell, its output switched to UTF-8 (no BOM), the XML written as it
|
|
652
|
+
* is. The name is a literal (psLiteral); our tasks sit in the root folder.
|
|
653
|
+
*/
|
|
654
|
+
export function buildTaskExportCommand(taskName) {
|
|
655
|
+
return [
|
|
656
|
+
"$ErrorActionPreference = 'Stop';",
|
|
657
|
+
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false;',
|
|
658
|
+
`[Console]::Out.Write((Export-ScheduledTask -TaskName ${psLiteral(taskName)} -TaskPath '\\'))`,
|
|
659
|
+
].join(' ');
|
|
660
|
+
}
|
|
661
|
+
|
|
337
662
|
// `startError` is set when the program did not run at all (not found, timed
|
|
338
663
|
// out): unlike an exit code, that says nothing about the task.
|
|
339
|
-
function defaultExecFileImpl(file, args) {
|
|
664
|
+
function defaultExecFileImpl(file, args, { timeout = QUERY_TIMEOUT_MS } = {}) {
|
|
340
665
|
return new Promise((resolve) => {
|
|
341
|
-
execFile(file, args, { windowsHide: true, timeout
|
|
666
|
+
execFile(file, args, { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
342
667
|
const exitCode = typeof error?.code === 'number' ? error.code : null;
|
|
343
668
|
resolve({
|
|
344
669
|
code: error ? (exitCode ?? 1) : 0,
|
|
@@ -380,6 +705,7 @@ export function createWindowsBootAutostartService({
|
|
|
380
705
|
const oarRoot = () => resolveOarRoot(env, platform);
|
|
381
706
|
const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
|
|
382
707
|
const xmlPath = () => pathImpl.join(oarRoot(), BOOT_TASK_XML_FILENAME);
|
|
708
|
+
const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
|
|
383
709
|
const elevationScriptPath = () => pathImpl.join(oarRoot(), BOOT_ELEVATION_SCRIPT_FILENAME);
|
|
384
710
|
|
|
385
711
|
function log(message) {
|
|
@@ -390,8 +716,10 @@ export function createWindowsBootAutostartService({
|
|
|
390
716
|
|
|
391
717
|
/**
|
|
392
718
|
* The task's XML, null when there is no such task, or `{ readError }` when
|
|
393
|
-
*
|
|
394
|
-
*
|
|
719
|
+
* it could not be read: schtasks did not run, it failed on a task that is
|
|
720
|
+
* there (a task only an administrator may read: one storing another
|
|
721
|
+
* account's password), or the export below failed. That says nothing about
|
|
722
|
+
* the task, so it must not read as "missing" (or as anyone's).
|
|
395
723
|
*/
|
|
396
724
|
async function queryTaskXml(taskName) {
|
|
397
725
|
let result;
|
|
@@ -405,7 +733,64 @@ export function createWindowsBootAutostartService({
|
|
|
405
733
|
// The details stay in the log (they can name folders on the host).
|
|
406
734
|
return { readError: 'schtasks could not be run; the relay log has the details.' };
|
|
407
735
|
}
|
|
408
|
-
|
|
736
|
+
if (result?.code !== 0) {
|
|
737
|
+
if (taskFileMissing(taskName)) return null;
|
|
738
|
+
log(`schtasks /query ${taskName} failed (exit code ${result?.code}): ${normalizeText(result?.stderr).split('\n')[0] || 'no detail'}`);
|
|
739
|
+
return { readError: `${bootTaskLabel(taskName)} could not be read; the relay log has the details.` };
|
|
740
|
+
}
|
|
741
|
+
return needsExport(result.stdout) ? exportTaskXml(taskName) : result.stdout;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Whether a task schtasks failed on is not there: its file under
|
|
746
|
+
* %SystemRoot%\System32\Tasks is not found. schtasks exits 1 alike for a
|
|
747
|
+
* missing task and one it may not read, and says which only in the
|
|
748
|
+
* system's language; the file is found (or access to it denied) either way
|
|
749
|
+
* for a task that is there.
|
|
750
|
+
*/
|
|
751
|
+
function taskFileMissing(taskName) {
|
|
752
|
+
try {
|
|
753
|
+
fsImpl.statSync(pathImpl.join(windowsSystemRoot(env), 'System32', 'Tasks', taskName));
|
|
754
|
+
return false;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return error?.code === 'ENOENT';
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* schtasks writes into a pipe in the console's code page, not UTF-8: a
|
|
762
|
+
* character outside ASCII comes out as a byte of that code page (ü as 0x81
|
|
763
|
+
* in 437), as a look-alike (Ł as L) or as `?` (日). Its output is taken as
|
|
764
|
+
* it is only when it is ASCII with no `?` after the declaration (no path
|
|
765
|
+
* holds one) and the OAR folder is ASCII too (else an L may be its Ł).
|
|
766
|
+
*/
|
|
767
|
+
function needsExport(xmlText) {
|
|
768
|
+
const body = String(xmlText || '').replace(/^\s*<\?xml[^>]*\?>/, '');
|
|
769
|
+
return /[^\x00-\x7f]|\?/.test(body) || /[^\x00-\x7f]/.test(oarRoot());
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// One export per task at a time: a poll while one runs shares its result.
|
|
773
|
+
const exportsInFlight = new Map();
|
|
774
|
+
function exportTaskXml(taskName) {
|
|
775
|
+
if (!exportsInFlight.has(taskName)) {
|
|
776
|
+
exportsInFlight.set(taskName, runTaskExport(taskName).finally(() => exportsInFlight.delete(taskName)));
|
|
777
|
+
}
|
|
778
|
+
return exportsInFlight.get(taskName);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The task's XML through PowerShell (buildTaskExportCommand), or `{ readError }`.
|
|
782
|
+
async function runTaskExport(taskName) {
|
|
783
|
+
let result;
|
|
784
|
+
try {
|
|
785
|
+
result = await execFileImpl(tool('powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', buildTaskExportCommand(taskName)], { timeout: EXPORT_TIMEOUT_MS });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
result = { code: 1, stdout: '', stderr: '', startError: describeExecError(error) };
|
|
788
|
+
}
|
|
789
|
+
const xml = String(result?.stdout || '').replace(/^\ufeff/, '');
|
|
790
|
+
if (result?.code === 0 && !result.startError && /<Task[\s>]/.test(xml)) return xml;
|
|
791
|
+
const why = result?.startError || normalizeText(result?.stderr).split('\n')[0] || `exit code ${result?.code}`;
|
|
792
|
+
log(`the task ${taskName} could not be exported to read its non-ASCII text exactly: ${why}`);
|
|
793
|
+
return { readError: 'the task could not be exported to read its non-ASCII text exactly; the relay log has the details.' };
|
|
409
794
|
}
|
|
410
795
|
|
|
411
796
|
function readLauncher() {
|
|
@@ -416,47 +801,185 @@ export function createWindowsBootAutostartService({
|
|
|
416
801
|
}
|
|
417
802
|
}
|
|
418
803
|
|
|
419
|
-
function generatedLauncher() {
|
|
420
|
-
return buildBootLauncherScript({
|
|
804
|
+
function generatedLauncher({ stateDirs, coverBytes = 0, nodePathOverride = null } = {}) {
|
|
805
|
+
return buildBootLauncherScript({
|
|
806
|
+
packageRoot,
|
|
807
|
+
nodePath: nodePathOverride || nodePath,
|
|
808
|
+
configPath,
|
|
809
|
+
...(stateDirs || resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl })),
|
|
810
|
+
workingDirectory: oarRoot(),
|
|
811
|
+
coverBytes,
|
|
812
|
+
pathImpl,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Brings a launcher an earlier version wrote (see
|
|
818
|
+
* buildEarlierBootLauncherScripts) up to date; the relay calls this once it
|
|
819
|
+
* runs. The task runs the launcher file, so this needs no elevation. Only a
|
|
820
|
+
* launcher exactly as that version generated it for this install and its
|
|
821
|
+
* config is rewritten, keeping the Node it runs; an edited one stays (it
|
|
822
|
+
* reads as customised, and enabling again replaces it, keeping a copy). It
|
|
823
|
+
* pins only the install's own folders (resolveLayoutStateDirs). True when
|
|
824
|
+
* it rewrote the launcher.
|
|
825
|
+
*/
|
|
826
|
+
// `onEarlierForm`: the earlier form found (`pinnedData` false: package database).
|
|
827
|
+
function refreshInstalledLauncher({ onEarlierForm } = {}) {
|
|
828
|
+
if (!supported || spawnsDisabled()) return false;
|
|
829
|
+
const stateDirs = resolveLayoutStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
|
|
830
|
+
if (!stateDirs) return false;
|
|
831
|
+
const current = readLauncher();
|
|
832
|
+
if (current === null) return false;
|
|
833
|
+
const launcherNodePath = readLauncherNodePath(current, serverPath());
|
|
834
|
+
if (!launcherNodePath) return false;
|
|
835
|
+
const earlier = buildEarlierBootLauncherScripts({
|
|
836
|
+
packageRoot, nodePath: launcherNodePath, configPath, ...stateDirs, workingDirectory: oarRoot(), pathImpl,
|
|
837
|
+
});
|
|
838
|
+
const matched = earlier.find((script) => launcherScriptsMatch(current, script, { ignoreCase: true }));
|
|
839
|
+
if (!matched) return false;
|
|
840
|
+
onEarlierForm?.({ path: launcherPath(), pinnedData: matched.includes('COPILOT_WEB_RELAY_DATA_DIR') });
|
|
841
|
+
writeFileAtomic(launcherPath(), generatedLauncher({
|
|
842
|
+
stateDirs,
|
|
843
|
+
nodePathOverride: launcherNodePath,
|
|
844
|
+
coverBytes: Buffer.byteLength(current, 'utf8'),
|
|
845
|
+
}));
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* A launcher OAR generates or an earlier version generated for this
|
|
851
|
+
* install (whatever Node it runs): one a mode change may delete.
|
|
852
|
+
*/
|
|
853
|
+
function launcherIsGenerated(launcher) {
|
|
854
|
+
try {
|
|
855
|
+
const ownNodePath = readLauncherNodePath(launcher, serverPath());
|
|
856
|
+
if (!ownNodePath) return false;
|
|
857
|
+
if (launcherScriptsMatch(launcher, generatedLauncher({ nodePathOverride: ownNodePath }))) return true;
|
|
858
|
+
return buildEarlierBootLauncherScripts({
|
|
859
|
+
packageRoot,
|
|
860
|
+
nodePath: ownNodePath,
|
|
861
|
+
configPath,
|
|
862
|
+
...resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl }),
|
|
863
|
+
workingDirectory: oarRoot(),
|
|
864
|
+
pathImpl,
|
|
865
|
+
}).some((earlier) => launcherScriptsMatch(launcher, earlier, { ignoreCase: true }));
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// The account this relay runs as (`whoami`), once: our task runs as it.
|
|
872
|
+
let userLookup = null;
|
|
873
|
+
function currentUser() {
|
|
874
|
+
userLookup ||= Promise.resolve()
|
|
875
|
+
.then(() => execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']))
|
|
876
|
+
.then((result) => parseUserFromWhoami(result?.stdout))
|
|
877
|
+
.catch(() => null)
|
|
878
|
+
.then((user) => {
|
|
879
|
+
if (!user) userLookup = null; // asked again next time
|
|
880
|
+
return user;
|
|
881
|
+
});
|
|
882
|
+
return userLookup;
|
|
421
883
|
}
|
|
422
884
|
|
|
423
885
|
/** What differs from the generated task and launcher, for a task that runs our launcher. */
|
|
424
|
-
function customisationsOf(xmlText) {
|
|
886
|
+
async function customisationsOf(xmlText) {
|
|
425
887
|
const differences = describeTaskCustomisations(xmlText, {
|
|
426
888
|
launcherPath: launcherPath(),
|
|
427
889
|
workingDirectory: oarRoot(),
|
|
890
|
+
user: await currentUser(),
|
|
428
891
|
});
|
|
429
892
|
const launcher = readLauncher();
|
|
430
893
|
if (launcher === null) differences.push('launcher-missing');
|
|
431
894
|
else {
|
|
432
895
|
try {
|
|
433
|
-
|
|
896
|
+
// Against the Node the launcher runs, as the repair keeps it: a moved
|
|
897
|
+
// Node is no customisation.
|
|
898
|
+
const ownNodePath = readLauncherNodePath(launcher, serverPath());
|
|
899
|
+
if (!launcherScriptsMatch(launcher, generatedLauncher({ nodePathOverride: ownNodePath }))) {
|
|
900
|
+
differences.push('launcher');
|
|
901
|
+
}
|
|
434
902
|
} catch {}
|
|
435
903
|
}
|
|
436
904
|
return differences;
|
|
437
905
|
}
|
|
438
906
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
907
|
+
/**
|
|
908
|
+
* The boot entries OAR did not generate, which no mode change deletes or
|
|
909
|
+
* writes over: a task under our name that runs something else, our task or
|
|
910
|
+
* launcher changed by hand (a missing launcher is no change), the
|
|
911
|
+
* pre-rebrand task, a hand-made launcher without our task.
|
|
912
|
+
*/
|
|
913
|
+
// Each `{ path, task, otherInstall }`: `task` names a scheduled task,
|
|
914
|
+
// `otherInstall` the package folder of another OAR install a launcher starts.
|
|
915
|
+
function handMadeEntries({ taskStatus, customisations = [], legacyTaskPresent }) {
|
|
916
|
+
const entries = [];
|
|
917
|
+
const task = (name) => ({ path: bootTaskLabel(name), task: name, otherInstall: null });
|
|
918
|
+
if (taskStatus === 'foreign') entries.push(task(BOOT_TASK_NAME));
|
|
919
|
+
if (taskStatus === 'ready' && customisations.some((name) => name !== 'launcher' && name !== 'launcher-missing')) {
|
|
920
|
+
entries.push(task(BOOT_TASK_NAME));
|
|
921
|
+
}
|
|
922
|
+
if (legacyTaskPresent) entries.push(task(LEGACY_BOOT_TASK_NAME));
|
|
923
|
+
const launcher = readLauncher();
|
|
924
|
+
if (launcher !== null && !launcherIsGenerated(launcher)) {
|
|
925
|
+
entries.push({ path: launcherPath(), task: null, otherInstall: otherInstallOf(launcher, packageRoot, pathImpl) });
|
|
926
|
+
}
|
|
927
|
+
return entries;
|
|
445
928
|
}
|
|
446
929
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
930
|
+
/**
|
|
931
|
+
* Before `replace` deletes or writes over hand-made boot entries: a copy
|
|
932
|
+
* of each, `<name>.bak` next to the launcher, and of each task its
|
|
933
|
+
* exported XML (`schtasks /create /xml` takes it back) in the OAR folder.
|
|
934
|
+
* The copies, each `{ path, backup }`, or null when one could not be written.
|
|
935
|
+
*/
|
|
936
|
+
async function backUpHandMade(entries) {
|
|
937
|
+
const copies = [];
|
|
938
|
+
try {
|
|
939
|
+
for (const entry of entries) {
|
|
940
|
+
let contents;
|
|
941
|
+
let target = entry.path;
|
|
942
|
+
if (entry.task) {
|
|
943
|
+
const xml = await queryTaskXml(entry.task);
|
|
944
|
+
if (xml === null) continue;
|
|
945
|
+
// Unread is no copy: nothing may be deleted without one.
|
|
946
|
+
if (typeof xml !== 'string') throw new Error(`${entry.path}: ${xml.readError}`);
|
|
947
|
+
contents = encodeTaskXml(xml);
|
|
948
|
+
target = pathImpl.join(oarRoot(), `${entry.task}.task.xml`);
|
|
949
|
+
} else {
|
|
950
|
+
contents = readLauncher();
|
|
951
|
+
if (contents === null) continue;
|
|
454
952
|
}
|
|
455
|
-
|
|
456
|
-
|
|
953
|
+
const copy = backupPathFor(target, oarRoot(), fsImpl, pathImpl);
|
|
954
|
+
fsImpl.mkdirSync(oarRoot(), { recursive: true });
|
|
955
|
+
fsImpl.writeFileSync(copy, contents);
|
|
956
|
+
copies.push({ path: entry.path, backup: copy });
|
|
957
|
+
log(`kept a copy of ${entry.path} as ${copy}`);
|
|
457
958
|
}
|
|
959
|
+
return copies;
|
|
960
|
+
} catch (error) {
|
|
961
|
+
log(`could not keep a copy before replacing: ${error?.message || error}`);
|
|
962
|
+
removeFiles(copies);
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function removeFiles(copies) {
|
|
968
|
+
for (const { backup } of copies) {
|
|
969
|
+
try { fsImpl.unlinkSync(backup); } catch {}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// The sign-in entries of this install, which boot mode replaces.
|
|
974
|
+
const signin = createWindowsAutostartService({ platform, env, packageRoot, nodePath, configPath, fsImpl, pathImpl });
|
|
975
|
+
|
|
976
|
+
function removeStartupCmds() {
|
|
977
|
+
try {
|
|
978
|
+
return signin.setEnabled(false).kept;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
log(`could not remove the sign-in entries: ${error?.message || error}`);
|
|
981
|
+
return [];
|
|
458
982
|
}
|
|
459
|
-
return removed;
|
|
460
983
|
}
|
|
461
984
|
|
|
462
985
|
function manualCommandFor(action) {
|
|
@@ -484,6 +1007,8 @@ export function createWindowsBootAutostartService({
|
|
|
484
1007
|
taskName: BOOT_TASK_NAME,
|
|
485
1008
|
legacyTaskPresent: false,
|
|
486
1009
|
launcherPath: null,
|
|
1010
|
+
generated: false,
|
|
1011
|
+
handMade: [],
|
|
487
1012
|
pendingElevation: null,
|
|
488
1013
|
lastError: null,
|
|
489
1014
|
manualCommand: manualCommandFor('enable'),
|
|
@@ -491,18 +1016,20 @@ export function createWindowsBootAutostartService({
|
|
|
491
1016
|
}
|
|
492
1017
|
const ownXml = await queryTaskXml(BOOT_TASK_NAME);
|
|
493
1018
|
const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
|
|
494
|
-
|
|
1019
|
+
// Either task unread: nothing may be taken out or replaced (mode unknown).
|
|
1020
|
+
const readError = ownXml?.readError || legacyXml?.readError || null;
|
|
495
1021
|
const legacyKnown = typeof legacyXml === 'string';
|
|
496
1022
|
let taskStatus;
|
|
497
|
-
if (readError) taskStatus = 'unknown';
|
|
1023
|
+
if (ownXml?.readError) taskStatus = 'unknown';
|
|
498
1024
|
else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
|
|
499
1025
|
else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
|
|
500
1026
|
// A task that runs our launcher but is not quite the one OAR registers (no
|
|
501
1027
|
// boot delay, an edited launcher) still is boot mode; it is reported,
|
|
502
1028
|
// never failed on.
|
|
503
|
-
const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
|
|
1029
|
+
const customisations = taskStatus === 'ready' ? await customisationsOf(ownXml) : [];
|
|
504
1030
|
let launcherPresent = false;
|
|
505
1031
|
try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
|
|
1032
|
+
const handMade = handMadeEntries({ taskStatus, customisations, legacyTaskPresent: legacyKnown });
|
|
506
1033
|
return {
|
|
507
1034
|
supported: true,
|
|
508
1035
|
platform,
|
|
@@ -512,6 +1039,10 @@ export function createWindowsBootAutostartService({
|
|
|
512
1039
|
launcherPath: launcherPresent ? launcherPath() : null,
|
|
513
1040
|
customised: customisations.length > 0,
|
|
514
1041
|
customisations,
|
|
1042
|
+
// Our task as OAR registers it, with a launcher OAR generated (or
|
|
1043
|
+
// none): the one boot entry a mode change may delete.
|
|
1044
|
+
generated: taskStatus === 'ready' && !handMade.some((entry) => entry.task === BOOT_TASK_NAME || entry.path === launcherPath()),
|
|
1045
|
+
handMade,
|
|
515
1046
|
launcherBackupPath,
|
|
516
1047
|
readError,
|
|
517
1048
|
pendingElevation: pending ? pending.action : null,
|
|
@@ -527,61 +1058,163 @@ export function createWindowsBootAutostartService({
|
|
|
527
1058
|
fsImpl.renameSync(temporaryPath, targetPath);
|
|
528
1059
|
}
|
|
529
1060
|
|
|
530
|
-
function settlePending(outcome) {
|
|
531
|
-
if (!pending) return;
|
|
532
|
-
if (pending.timer) clearTimeoutImpl(pending.timer);
|
|
533
|
-
pending = null;
|
|
534
|
-
lastError = outcome || null;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
1061
|
/**
|
|
538
|
-
* The elevated child is powershell running Start-Process -Verb RunAs -Wait
|
|
539
|
-
* the outer process lives exactly as long as the
|
|
540
|
-
*
|
|
1062
|
+
* The elevated child is powershell running Start-Process -Verb RunAs -Wait
|
|
1063
|
+
* (buildElevationCommand): the outer process lives exactly as long as the
|
|
1064
|
+
* UAC prompt plus the sweep, so its exit is the signal. `onOutcome('ok' |
|
|
1065
|
+
* 'declined' | 'failed', detail)`: a declined prompt changed nothing; a
|
|
1066
|
+
* failed sweep may have run (it puts back what it deleted), and a timeout
|
|
1067
|
+
* leaves the prompt open, so both keep every copy.
|
|
541
1068
|
*/
|
|
542
|
-
function spawnElevated(action,
|
|
1069
|
+
function spawnElevated(action, script, onOutcome) {
|
|
543
1070
|
const powershell = tool('powershell.exe');
|
|
544
1071
|
const child = spawnImpl(powershell, [
|
|
545
1072
|
'-NoProfile',
|
|
546
1073
|
'-NonInteractive',
|
|
547
1074
|
'-Command',
|
|
548
|
-
|
|
549
|
-
`$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
|
|
550
|
-
`'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
|
|
551
|
-
'; exit $p.ExitCode',
|
|
552
|
-
].join(' '),
|
|
1075
|
+
buildElevationCommand({ powershellPath: powershell, script }),
|
|
553
1076
|
], {
|
|
554
1077
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
555
1078
|
windowsHide: true,
|
|
556
1079
|
});
|
|
557
1080
|
let stderr = '';
|
|
558
1081
|
child.stderr?.on?.('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-2000); });
|
|
1082
|
+
const settle = (outcome, detail) => {
|
|
1083
|
+
if (!pending) return; // settled already
|
|
1084
|
+
if (pending.timer) clearTimeoutImpl(pending.timer);
|
|
1085
|
+
pending = null;
|
|
1086
|
+
onOutcome(outcome, detail);
|
|
1087
|
+
};
|
|
559
1088
|
const timer = setTimeoutImpl(() => {
|
|
560
1089
|
try { child.kill('SIGTERM'); } catch {}
|
|
561
|
-
|
|
562
|
-
onSettled?.(false);
|
|
1090
|
+
settle('failed', 'no answer at the administrator prompt within 2 minutes');
|
|
563
1091
|
}, ELEVATION_TIMEOUT_MS);
|
|
564
1092
|
timer?.unref?.();
|
|
565
1093
|
pending = { action, startedAt: now(), timer, child };
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
});
|
|
1094
|
+
// Nothing elevated ran when the prompt was declined, or when neither
|
|
1095
|
+
// PowerShell could be started: those put everything back.
|
|
1096
|
+
child.on('error', (error) => settle('declined', `elevation could not be requested: ${error?.message || error}`));
|
|
570
1097
|
child.on('close', (code) => {
|
|
571
|
-
if (
|
|
572
|
-
if (code ===
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
} else {
|
|
576
|
-
// 1 from Start-Process usually means the UAC prompt was declined.
|
|
577
|
-
const detail = normalizeText(stderr).split('\n')[0] || `exit code ${code}`;
|
|
578
|
-
settlePending(`${action} was not confirmed (${detail}). Run the command below from an elevated shell instead.`);
|
|
579
|
-
onSettled?.(false);
|
|
580
|
-
}
|
|
1098
|
+
if (code === 0) settle('ok');
|
|
1099
|
+
else if (code === ELEVATION_DECLINED_EXIT_CODE) settle('declined');
|
|
1100
|
+
else if (code === ELEVATION_NOT_STARTED_EXIT_CODE) settle('declined', `the elevated step could not be started: ${normalizeText(stderr).split('\n')[0] || 'no detail'}`);
|
|
1101
|
+
else settle('failed', code === SWEEP_FAILED_EXIT_CODE ? 'schtasks failed' : (normalizeText(stderr).split('\n')[0] || `exit code ${code}`));
|
|
581
1102
|
});
|
|
582
1103
|
}
|
|
583
1104
|
|
|
584
|
-
|
|
1105
|
+
const COPY_FAILED = 'Could not keep a copy of an entry before changing it, so nothing was changed; the relay log has the details.';
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* A copy of each entry before a sweep deletes it or the launcher is
|
|
1109
|
+
* written over: `<name>.bak` beside the launcher, a task's exported XML
|
|
1110
|
+
* (`schtasks /create /xml` takes it back) in the OAR folder. Each copy is
|
|
1111
|
+
* `{ path, backup, task, generated }`; null when one could not be written.
|
|
1112
|
+
*/
|
|
1113
|
+
async function copyEntries(entries) {
|
|
1114
|
+
const copies = [];
|
|
1115
|
+
try {
|
|
1116
|
+
for (const entry of entries) {
|
|
1117
|
+
let contents = entry.contents;
|
|
1118
|
+
let target = entry.path;
|
|
1119
|
+
if (entry.task) {
|
|
1120
|
+
const xml = await queryTaskXml(entry.task);
|
|
1121
|
+
if (xml === null) continue;
|
|
1122
|
+
// Unread is no copy: nothing may be deleted without one.
|
|
1123
|
+
if (typeof xml !== 'string') throw new Error(`${entry.path}: ${xml.readError}`);
|
|
1124
|
+
contents = encodeTaskXml(xml);
|
|
1125
|
+
target = pathImpl.join(oarRoot(), `${entry.task}.task.xml`);
|
|
1126
|
+
}
|
|
1127
|
+
if (contents == null) continue;
|
|
1128
|
+
const backup = backupPathFor(target, oarRoot(), fsImpl, pathImpl);
|
|
1129
|
+
fsImpl.mkdirSync(oarRoot(), { recursive: true });
|
|
1130
|
+
fsImpl.writeFileSync(backup, contents);
|
|
1131
|
+
copies.push({ path: entry.path, backup, task: entry.task || null, generated: Boolean(entry.generated) });
|
|
1132
|
+
log(`kept a copy of ${entry.path} as ${backup}`);
|
|
1133
|
+
}
|
|
1134
|
+
return copies;
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
log(`could not keep a copy before changing an entry: ${error?.message || error}`);
|
|
1137
|
+
removeFiles(copies);
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// The tasks a sweep deletes, generated or not (`state.handMade`).
|
|
1143
|
+
function tasksToCopy(state, names) {
|
|
1144
|
+
return names.map((name) => ({
|
|
1145
|
+
path: bootTaskLabel(name),
|
|
1146
|
+
task: name,
|
|
1147
|
+
generated: !state.handMade.some((entry) => entry.task === name),
|
|
1148
|
+
}));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function restoreLauncher(previous) {
|
|
1152
|
+
try {
|
|
1153
|
+
if (previous === null) fsImpl.unlinkSync(launcherPath());
|
|
1154
|
+
else writeFileAtomic(launcherPath(), previous);
|
|
1155
|
+
return true;
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
log(`could not put the launcher back: ${error?.message || error}`);
|
|
1158
|
+
return previous === null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Each copied task as read afterwards: one that could not be read is not
|
|
1163
|
+
// said to be there, or gone.
|
|
1164
|
+
async function tasksAfterwards(copies) {
|
|
1165
|
+
const lines = [];
|
|
1166
|
+
for (const copy of copies.filter((entry) => entry.task)) {
|
|
1167
|
+
const xml = await queryTaskXml(copy.task);
|
|
1168
|
+
if (typeof xml === 'string') lines.push(`${copy.path} is registered`);
|
|
1169
|
+
else if (xml === null) lines.push(`${copy.path} is not registered`);
|
|
1170
|
+
else lines.push(`${copy.path} could not be read, so whether it is registered is not known`);
|
|
1171
|
+
}
|
|
1172
|
+
return lines.length ? ` Afterwards: ${lines.join('; ')}.` : '';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function failureText(action, detail, copies) {
|
|
1176
|
+
const what = action === 'enable' ? 'registered' : 'removed';
|
|
1177
|
+
const kept = copies.length ? ` Copies kept: ${copies.map((copy) => copy.backup).join(', ')}.` : '';
|
|
1178
|
+
return `The boot task could not be ${what} (${detail}).${await tasksAfterwards(copies)}${kept} Run the command below from an elevated shell instead.`;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Confirmed (exit 0), but the task could not be read back: nothing is
|
|
1182
|
+
// undone or claimed, and every file stays.
|
|
1183
|
+
function unverifiedText(action, readError, copies) {
|
|
1184
|
+
const what = action === 'enable' ? 'registered' : 'removed';
|
|
1185
|
+
const kept = copies.length ? ` Copies kept: ${copies.map((copy) => copy.backup).join(', ')}.` : '';
|
|
1186
|
+
return `The administrator prompt was confirmed and the boot task should be ${what}, but it could not be read back to check (${readError}). Nothing was undone; the launcher stays.${kept}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* After a declined prompt nothing ran: a copy goes once what it copies is
|
|
1191
|
+
* verified in place (a task still registered, the launcher put back).
|
|
1192
|
+
*/
|
|
1193
|
+
async function dropCopiesAfterDecline(copies, launcherBack) {
|
|
1194
|
+
const left = [];
|
|
1195
|
+
for (const copy of copies) {
|
|
1196
|
+
const inPlace = copy.task ? typeof (await queryTaskXml(copy.task)) === 'string' : launcherBack;
|
|
1197
|
+
if (inPlace) removeFiles([copy]);
|
|
1198
|
+
else left.push(copy);
|
|
1199
|
+
}
|
|
1200
|
+
return left;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Registers our task, elevated. Refused while a boot entry OAR did not
|
|
1205
|
+
* make is there (`handMade`: a task under our name or the pre-rebrand
|
|
1206
|
+
* one, our task or launcher changed by hand), unless `replace`. Every task
|
|
1207
|
+
* the sweep deletes and a launcher it writes over are copied first
|
|
1208
|
+
* (copyEntries). `onSettled(ok, { kept, copies, declined, error })` once
|
|
1209
|
+
* the elevation ends: `kept` the hand-made sign-in entries a confirmed
|
|
1210
|
+
* enable left, `copies` the copies still there. Confirmed and verified
|
|
1211
|
+
* (our task is registered), the copies of generated entries go and those
|
|
1212
|
+
* of hand-made ones stay; declined, the launcher is put back and the
|
|
1213
|
+
* copies go; failed, the launcher is put back and every copy stays.
|
|
1214
|
+
* Confirmed but our task not readable afterwards (`unverified`): nothing
|
|
1215
|
+
* is undone, every file stays, and it is no success.
|
|
1216
|
+
*/
|
|
1217
|
+
async function requestEnable({ onSettled, replace = false } = {}) {
|
|
585
1218
|
if (!supported) {
|
|
586
1219
|
throw new Error('Windows boot autostart is only available on Windows.');
|
|
587
1220
|
}
|
|
@@ -591,51 +1224,101 @@ export function createWindowsBootAutostartService({
|
|
|
591
1224
|
if (pending) {
|
|
592
1225
|
return { ...(await getState()), accepted: false };
|
|
593
1226
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
|
|
598
|
-
// An edited (or older) launcher is replaced, but kept: one copy, the
|
|
599
|
-
// latest replaced one.
|
|
600
|
-
const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
|
|
601
|
-
try {
|
|
602
|
-
fsImpl.writeFileSync(backupPath, previous);
|
|
603
|
-
launcherBackupPath = backupPath;
|
|
604
|
-
log(`the launcher differed from the generated one; saved it as ${backupPath}`);
|
|
605
|
-
} catch (error) {
|
|
606
|
-
log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
|
|
607
|
-
lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
|
|
608
|
-
return { ...(await getState()), accepted: false };
|
|
609
|
-
}
|
|
1227
|
+
const before = await getState();
|
|
1228
|
+
if (before.readError) {
|
|
1229
|
+
return { ...before, accepted: false, refused: `the boot task could not be read (${before.readError})` };
|
|
610
1230
|
}
|
|
611
|
-
|
|
1231
|
+
if (before.handMade.length && !replace) {
|
|
1232
|
+
return { ...before, accepted: false, refused: handMadeLines(before.handMade, 'boot').join(' ') };
|
|
1233
|
+
}
|
|
1234
|
+
lastError = null;
|
|
612
1235
|
const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
|
|
613
1236
|
const sid = parseSidFromWhoami(who.stdout);
|
|
614
1237
|
if (!sid) {
|
|
615
1238
|
lastError = 'Could not determine the current user SID (whoami /user failed).';
|
|
616
1239
|
return { ...(await getState()), accepted: false };
|
|
617
1240
|
}
|
|
1241
|
+
const launcher = generatedLauncher();
|
|
1242
|
+
const previous = readLauncher();
|
|
1243
|
+
const deleted = [
|
|
1244
|
+
...(['ready', 'foreign'].includes(before.taskStatus) ? [BOOT_TASK_NAME] : []),
|
|
1245
|
+
...(before.legacyTaskPresent ? [LEGACY_BOOT_TASK_NAME] : []),
|
|
1246
|
+
];
|
|
1247
|
+
const copies = await copyEntries([
|
|
1248
|
+
...tasksToCopy(before, deleted),
|
|
1249
|
+
...(previous !== null && !launcherScriptsMatch(previous, launcher)
|
|
1250
|
+
? [{ path: launcherPath(), contents: previous, generated: launcherIsGenerated(previous) }]
|
|
1251
|
+
: []),
|
|
1252
|
+
]);
|
|
1253
|
+
if (copies === null) {
|
|
1254
|
+
lastError = COPY_FAILED;
|
|
1255
|
+
return { ...(await getState()), accepted: false };
|
|
1256
|
+
}
|
|
1257
|
+
launcherBackupPath = copies.find((copy) => copy.path === launcherPath())?.backup || null;
|
|
1258
|
+
const putBackFiles = snapshotFiles([xmlPath()]);
|
|
1259
|
+
writeFileAtomic(launcherPath(), generatedLauncher({ coverBytes: previous === null ? 0 : Buffer.byteLength(previous, 'utf8') }));
|
|
618
1260
|
writeFileAtomic(xmlPath(), encodeTaskXml(buildBootTaskXml({
|
|
619
1261
|
sid,
|
|
620
1262
|
launcherPath: launcherPath(),
|
|
621
1263
|
packageRoot: pathImpl.resolve(String(packageRoot)),
|
|
622
1264
|
workingDirectory: oarRoot(),
|
|
623
1265
|
})));
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1266
|
+
const script = buildElevationScript({
|
|
1267
|
+
action: 'enable',
|
|
1268
|
+
xmlPath: xmlPath(),
|
|
1269
|
+
schtasksPath: tool('schtasks'),
|
|
1270
|
+
restore: copies.filter((copy) => copy.task).map((copy) => ({ task: copy.task, xml: copy.backup })),
|
|
1271
|
+
});
|
|
1272
|
+
spawnElevated('enable', script, async (outcome, detail) => {
|
|
1273
|
+
let kept = [];
|
|
1274
|
+
let left = copies;
|
|
1275
|
+
const after = outcome === 'ok' ? await getState() : null;
|
|
1276
|
+
if (after?.taskStatus === 'unknown') {
|
|
1277
|
+
// Registered, as far as the sweep knows; only a task known to be
|
|
1278
|
+
// missing or someone else's undoes the launcher.
|
|
1279
|
+
writeScriptFile(script);
|
|
1280
|
+
lastError = unverifiedText('enable', after.readError, copies);
|
|
1281
|
+
log(`enable not verified: ${lastError}`);
|
|
1282
|
+
onSettled?.(false, { kept, copies, unverified: true, reason: null, error: lastError });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (after?.taskStatus === 'ready') {
|
|
1286
|
+
removeFiles(copies.filter((copy) => copy.generated));
|
|
1287
|
+
left = copies.filter((copy) => !copy.generated);
|
|
627
1288
|
// Mutual exclusion: boot mode owns startup now, so the sign-in
|
|
628
|
-
// entries
|
|
629
|
-
|
|
630
|
-
|
|
1289
|
+
// entries OAR generated must not race it at the next logon.
|
|
1290
|
+
kept = removeStartupCmds();
|
|
1291
|
+
writeScriptFile(script);
|
|
1292
|
+
log(`enabled; ${kept.length ? `left ${kept.length} hand-made sign-in entr${kept.length === 1 ? 'y' : 'ies'}` : 'no sign-in entry left'}`);
|
|
1293
|
+
onSettled?.(true, { kept, copies: left });
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const launcherBack = restoreLauncher(previous);
|
|
1297
|
+
if (outcome === 'declined') {
|
|
1298
|
+
putBackFiles();
|
|
1299
|
+
left = await dropCopiesAfterDecline(copies, launcherBack);
|
|
1300
|
+
lastError = declineText(detail);
|
|
631
1301
|
} else {
|
|
632
|
-
|
|
1302
|
+
lastError = await failureText('enable', outcome === 'ok' ? 'it is not registered afterwards' : detail, copies);
|
|
633
1303
|
}
|
|
1304
|
+
launcherBackupPath = null;
|
|
1305
|
+
log(`enable did not complete: ${lastError}`);
|
|
1306
|
+
onSettled?.(false, { kept, copies: left, declined: outcome === 'declined', reason: detail || null, error: lastError });
|
|
634
1307
|
});
|
|
635
1308
|
return { ...(await getState()), accepted: true };
|
|
636
1309
|
}
|
|
637
1310
|
|
|
638
|
-
|
|
1311
|
+
/**
|
|
1312
|
+
* Deletes our task, elevated, and then its launcher; only when both are as
|
|
1313
|
+
* OAR generates them (`generated`). Anything hand-made (`handMade`) stays
|
|
1314
|
+
* as it is: then nothing is deleted and nothing elevated. With `replace`
|
|
1315
|
+
* the hand-made ones go too (our task, the pre-rebrand one, the launcher).
|
|
1316
|
+
* The tasks are copied first, as for requestEnable, and so is a hand-made
|
|
1317
|
+
* launcher; the launcher goes only once the tasks are verified gone (not
|
|
1318
|
+
* readable afterwards: `unverified`, everything stays).
|
|
1319
|
+
* `onSettled(ok, { copies, declined, unverified, error })`.
|
|
1320
|
+
*/
|
|
1321
|
+
async function requestDisable({ onSettled, replace = false } = {}) {
|
|
639
1322
|
if (!supported) {
|
|
640
1323
|
throw new Error('Windows boot autostart is only available on Windows.');
|
|
641
1324
|
}
|
|
@@ -645,27 +1328,121 @@ export function createWindowsBootAutostartService({
|
|
|
645
1328
|
if (pending) {
|
|
646
1329
|
return { ...(await getState()), accepted: false };
|
|
647
1330
|
}
|
|
1331
|
+
const before = await getState();
|
|
1332
|
+
if (before.readError) {
|
|
1333
|
+
return { ...before, accepted: false, refused: `the boot task could not be read (${before.readError})` };
|
|
1334
|
+
}
|
|
1335
|
+
if (!before.generated && !(replace && before.handMade.length)) return { ...before, accepted: false };
|
|
648
1336
|
lastError = null;
|
|
649
1337
|
launcherBackupPath = null;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1338
|
+
const deleted = [
|
|
1339
|
+
...(['ready', 'foreign'].includes(before.taskStatus) ? [BOOT_TASK_NAME] : []),
|
|
1340
|
+
...(replace && before.legacyTaskPresent ? [LEGACY_BOOT_TASK_NAME] : []),
|
|
1341
|
+
];
|
|
1342
|
+
const launcherHandMade = before.handMade.some((entry) => entry.path === launcherPath());
|
|
1343
|
+
const copies = await copyEntries([
|
|
1344
|
+
...tasksToCopy(before, deleted),
|
|
1345
|
+
...(replace && launcherHandMade ? [{ path: launcherPath(), contents: readLauncher(), generated: false }] : []),
|
|
1346
|
+
]);
|
|
1347
|
+
if (copies === null) {
|
|
1348
|
+
lastError = COPY_FAILED;
|
|
1349
|
+
return { ...(await getState()), accepted: false };
|
|
1350
|
+
}
|
|
1351
|
+
const removeLauncher = () => {
|
|
1352
|
+
const launcher = readLauncher();
|
|
1353
|
+
try {
|
|
1354
|
+
// Unless it was changed meanwhile, or is a copy's original.
|
|
1355
|
+
if (launcher !== null && (launcherIsGenerated(launcher) || copies.some((copy) => copy.path === launcherPath()))) {
|
|
1356
|
+
fsImpl.unlinkSync(launcherPath());
|
|
1357
|
+
}
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
log(`could not remove launcher: ${error?.message || error}`);
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
if (!deleted.length) {
|
|
1363
|
+
// Only a launcher no task runs: nothing to elevate.
|
|
1364
|
+
removeLauncher();
|
|
1365
|
+
onSettled?.(true, { copies });
|
|
1366
|
+
return { ...(await getState()), accepted: true };
|
|
1367
|
+
}
|
|
1368
|
+
const script = buildElevationScript({
|
|
1369
|
+
action: 'disable',
|
|
1370
|
+
schtasksPath: tool('schtasks'),
|
|
1371
|
+
includeLegacy: deleted.includes(LEGACY_BOOT_TASK_NAME),
|
|
1372
|
+
restore: copies.filter((copy) => copy.task).map((copy) => ({ task: copy.task, xml: copy.backup })),
|
|
1373
|
+
});
|
|
1374
|
+
spawnElevated('disable', script, async (outcome, detail) => {
|
|
1375
|
+
let left = copies;
|
|
1376
|
+
if (outcome === 'ok') {
|
|
1377
|
+
const after = await getState();
|
|
1378
|
+
if (after.readError) {
|
|
1379
|
+
// Not read back: never claimed removed; the launcher and every copy stay.
|
|
1380
|
+
lastError = unverifiedText('disable', after.readError, copies);
|
|
1381
|
+
log(`disable not verified: ${lastError}`);
|
|
1382
|
+
onSettled?.(false, { copies, unverified: true, reason: null, error: lastError });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const gone = !['ready', 'foreign'].includes(after.taskStatus) && !(deleted.includes(LEGACY_BOOT_TASK_NAME) && after.legacyTaskPresent);
|
|
1386
|
+
if (gone) {
|
|
1387
|
+
removeLauncher();
|
|
1388
|
+
// Out of boot mode, the task XML and the record of the last sweep go too.
|
|
1389
|
+
for (const file of [xmlPath(), elevationScriptPath()]) {
|
|
1390
|
+
try { if (fsImpl.existsSync(file)) fsImpl.unlinkSync(file); } catch {}
|
|
1391
|
+
}
|
|
1392
|
+
removeFiles(copies.filter((copy) => copy.generated));
|
|
1393
|
+
left = copies.filter((copy) => !copy.generated);
|
|
1394
|
+
log('disabled');
|
|
1395
|
+
onSettled?.(true, { copies: left });
|
|
1396
|
+
return;
|
|
657
1397
|
}
|
|
658
|
-
|
|
1398
|
+
}
|
|
1399
|
+
if (outcome === 'declined') {
|
|
1400
|
+
left = await dropCopiesAfterDecline(copies, true);
|
|
1401
|
+
lastError = declineText(detail);
|
|
659
1402
|
} else {
|
|
660
|
-
|
|
1403
|
+
lastError = await failureText('disable', outcome === 'ok' ? 'it is still registered afterwards' : detail, copies);
|
|
661
1404
|
}
|
|
1405
|
+
log(`disable did not complete: ${lastError}`);
|
|
1406
|
+
onSettled?.(false, { copies: left, declined: outcome === 'declined', reason: detail || null, error: lastError });
|
|
662
1407
|
});
|
|
663
1408
|
return { ...(await getState()), accepted: true };
|
|
664
1409
|
}
|
|
665
1410
|
|
|
1411
|
+
// The files as they are now; the returned function puts them back (after
|
|
1412
|
+
// a declined prompt nothing of the request is left).
|
|
1413
|
+
function snapshotFiles(files) {
|
|
1414
|
+
const saved = files.map((file) => {
|
|
1415
|
+
try { return { file, contents: fsImpl.existsSync(file) ? fsImpl.readFileSync(file) : null }; } catch { return { file, contents: null }; }
|
|
1416
|
+
});
|
|
1417
|
+
return () => {
|
|
1418
|
+
for (const { file, contents } of saved) {
|
|
1419
|
+
try {
|
|
1420
|
+
if (contents === null) { if (fsImpl.existsSync(file)) fsImpl.unlinkSync(file); } else fsImpl.writeFileSync(file, contents);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
log(`could not put back ${file}: ${error?.message || error}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function declineText(detail) {
|
|
1429
|
+
return detail ? `Nothing was changed: ${detail}.` : 'The administrator prompt was declined; nothing was changed.';
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// A record of the last confirmed sweep beside the task XML (none before
|
|
1433
|
+
// the prompt is answered, so a declined one leaves none), UTF-8 with a BOM so
|
|
1434
|
+
// Windows PowerShell reads a non-ASCII path in it right when run by hand.
|
|
1435
|
+
function writeScriptFile(script) {
|
|
1436
|
+
try {
|
|
1437
|
+
writeFileAtomic(elevationScriptPath(), `${script}`);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
log(`could not write ${elevationScriptPath()}: ${error?.message || error}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
666
1442
|
return {
|
|
667
1443
|
getState,
|
|
668
1444
|
requestEnable,
|
|
669
1445
|
requestDisable,
|
|
1446
|
+
refreshInstalledLauncher,
|
|
670
1447
|
};
|
|
671
1448
|
}
|