@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.
Files changed (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. 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
- LEGACY_WINDOWS_AUTOSTART_FILENAME,
13
- WINDOWS_AUTOSTART_FILENAME,
14
+ autostartWaitLines,
15
+ backupPathFor,
16
+ buildLauncherFile,
17
+ createWindowsAutostartService,
14
18
  escapeBatchValue,
15
- resolveWindowsStartupDirectory,
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
- export const BOOT_TEMP_WAIT_SECONDS = 120;
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, the launcher waits up to BOOT_TEMP_WAIT_SECONDS for the
80
- * %TEMP% folder: at boot it can live on a drive that a startup script creates
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
- if (!normalizeText(packageRoot)) {
98
- throw new Error('Windows boot autostart requires the package root.');
99
- }
100
- if (!normalizeText(nodePath)) {
101
- throw new Error('Windows boot autostart requires the Node.js executable path.');
102
- }
103
- const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
104
- const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
105
- const resolvedNodePath = pathImpl.resolve(nodePath);
106
- const lines = [
107
- '@echo off',
108
- 'rem Generated by OAR settings (system-startup autostart). Regenerated on',
109
- 'rem every settings change - do not edit by hand.',
110
- `set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
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). Only what the generated
256
- * XML sets is compared: Task Scheduler adds its own defaults on export, and
257
- * the run level, for one, is not set by OAR.
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
- const parsed = parseTaskXml(xmlText);
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
- if (!sameText(parsed.command, 'C:\\Windows\\System32\\cmd.exe')
263
- || normalizeText(parsed.argumentsLine).replace(/\s+/g, ' ').toLowerCase()
264
- !== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
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(parsed.workingDirectory, workingDirectory)) {
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
- /** Same launcher, whatever the line endings and trailing blanks. */
277
- export function launcherScriptsMatch(actual, expected) {
278
- const normalize = (value) => String(value == null ? '' : value)
279
- .split(/\r?\n/)
280
- .map((line) => line.replace(/\s+$/, ''))
281
- .join('\n')
282
- .trim();
283
- return normalize(actual) === normalize(expected);
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) ? `& "${schtasksPath}"` : 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
- return [
313
- `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
314
- `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
315
- `${schtasks} /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
316
- 'exit $LASTEXITCODE',
317
- '',
318
- ].join('\r\n');
319
- }
320
- if (action === 'disable') {
321
- return [
322
- `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
323
- `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
324
- 'exit 0',
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
- throw new Error(`Unknown elevation action: ${JSON.stringify(action)}`);
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: QUERY_TIMEOUT_MS }, (error, stdout, stderr) => {
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
- * schtasks itself could not run: that says nothing about the task, so it
394
- * must not read as "missing".
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
- return result?.code === 0 ? result.stdout : null;
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({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl });
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
- if (!launcherScriptsMatch(launcher, generatedLauncher())) differences.push('launcher');
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
- function startupCmdPaths() {
440
- const dir = resolveWindowsStartupDirectory(env, pathImpl);
441
- return [
442
- pathImpl.join(dir, WINDOWS_AUTOSTART_FILENAME),
443
- pathImpl.join(dir, LEGACY_WINDOWS_AUTOSTART_FILENAME),
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
- function removeStartupCmds() {
448
- let removed = 0;
449
- for (const target of startupCmdPaths()) {
450
- try {
451
- if (fsImpl.existsSync(target)) {
452
- fsImpl.unlinkSync(target);
453
- removed += 1;
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
- } catch (error) {
456
- log(`could not remove ${target}: ${error?.message || error}`);
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
- const readError = ownXml?.readError || null;
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 UAC prompt plus the sweep
540
- * script, so its exit is the "elevation finished (or was declined)" signal.
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, onSettled) {
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
- settlePending(`${action} timed out waiting for the admin confirmation on the PC.`);
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
- child.on('error', (error) => {
567
- settlePending(`${action} could not request elevation: ${error?.message || error}`);
568
- onSettled?.(false);
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 (!pending) return; // timeout already settled it
572
- if (code === 0) {
573
- settlePending(null);
574
- onSettled?.(true);
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
- async function requestEnable() {
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
- lastError = null;
595
- const launcher = generatedLauncher();
596
- const previous = readLauncher();
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
- writeFileAtomic(launcherPath(), launcher);
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
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath(), schtasksPath: tool('schtasks') }));
625
- spawnElevated('enable', (ok) => {
626
- if (ok) {
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 (either name) must not race it at the next logon.
629
- const removed = removeStartupCmds();
630
- log(`enabled; removed ${removed} sign-in autostart entr${removed === 1 ? 'y' : 'ies'}`);
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
- log(`enable did not complete: ${lastError}`);
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
- async function requestDisable() {
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
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable', schtasksPath: tool('schtasks') }));
651
- spawnElevated('disable', (ok) => {
652
- if (ok) {
653
- try {
654
- if (fsImpl.existsSync(launcherPath())) fsImpl.unlinkSync(launcherPath());
655
- } catch (error) {
656
- log(`could not remove launcher: ${error?.message || error}`);
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
- log('disabled');
1398
+ }
1399
+ if (outcome === 'declined') {
1400
+ left = await dropCopiesAfterDecline(copies, true);
1401
+ lastError = declineText(detail);
659
1402
  } else {
660
- log(`disable did not complete: ${lastError}`);
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
  }