@oar-sh/oar 0.9.14 → 0.9.16

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 (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -7,18 +7,19 @@ import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layou
7
7
  import {
8
8
  describeExecError,
9
9
  describeSpawnEnvironment,
10
+ windowsSystemRoot,
10
11
  windowsSystemToolPath,
11
12
  } from '../../shared/windows-system-tools.mjs';
12
13
  import {
13
- LEGACY_WINDOWS_AUTOSTART_FILENAME,
14
- WINDOWS_AUTOSTART_FILENAME,
15
14
  autostartWaitLines,
15
+ backupPathFor,
16
16
  buildLauncherFile,
17
+ createWindowsAutostartService,
17
18
  escapeBatchValue,
18
19
  launcherNodeLine,
19
20
  launcherScriptsMatch,
21
+ otherInstallOf,
20
22
  readLauncherNodePath,
21
- resolveWindowsStartupDirectory,
22
23
  stateDirLines,
23
24
  } from './windows-autostart-service.mjs';
24
25
 
@@ -60,6 +61,9 @@ const ELEVATION_TIMEOUT_MS = 120_000;
60
61
  export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
61
62
  // Each schtasks query; the settings page waits on two of them.
62
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;
63
67
 
64
68
  function normalizeText(value) {
65
69
  return String(value == null ? '' : value).trim();
@@ -337,27 +341,134 @@ function samePath(a, b) {
337
341
  return normalize(a) === normalize(b);
338
342
  }
339
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
+
340
422
  /**
341
423
  * How a task that runs our launcher differs from the one buildBootTaskXml
342
- * registers, as short names (empty when it does not). Only what the generated
343
- * XML sets is compared: Task Scheduler adds its own defaults on export, and
344
- * 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`.
345
431
  */
346
- export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
347
- 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'];
348
438
  const differences = [];
349
- if (!sameText(parsed.command, 'C:\\Windows\\System32\\cmd.exe')
350
- || normalizeText(parsed.argumentsLine).replace(/\s+/g, ' ').toLowerCase()
351
- !== `/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()) {
352
457
  differences.push('action');
353
458
  }
354
- if (normalizeText(workingDirectory) && !samePath(parsed.workingDirectory, workingDirectory)) {
459
+ if (normalizeText(workingDirectory) && !samePath(field(exec, 'WorkingDirectory'), workingDirectory)) {
355
460
  differences.push('working-directory');
356
461
  }
357
- if (!sameText(parsed.logonType, 'S4U')) differences.push('logon-type');
358
- if (!parsed.hasBootTrigger) differences.push('trigger');
359
- else if (!sameText(parsed.bootDelay, 'PT30S')) differences.push('boot-delay');
360
- return differences;
462
+
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)];
361
472
  }
362
473
 
363
474
  // Shared with the sign-in entry; still exported from here for its callers.
@@ -377,35 +488,150 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
377
488
  return 'foreign';
378
489
  }
379
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
+
380
505
  /**
381
506
  * The elevated sweep. Everything the admin token is actually needed for —
382
507
  * and nothing else — in one script, so one UAC covers the whole transition
383
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.
384
515
  */
385
- export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
516
+ export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks', includeLegacy = false, restore = [] } = {}) {
386
517
  // PowerShell runs a quoted path only behind the call operator.
387
- 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;
388
522
  if (action === 'enable') {
389
523
  if (!normalizeText(xmlPath)) {
390
524
  throw new Error('The enable sweep needs the task XML path.');
391
525
  }
392
- return [
393
- `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
394
- `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
395
- `${schtasks} /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
396
- 'exit $LASTEXITCODE',
397
- '',
398
- ].join('\r\n');
399
- }
400
- if (action === 'disable') {
401
- return [
402
- `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
403
- `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
404
- 'exit 0',
405
- '',
406
- ].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)}`);
407
539
  }
408
- 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
+ ];
409
635
  }
410
636
 
411
637
  /** `whoami /user /fo csv` → the current user's SID. */
@@ -414,11 +640,30 @@ export function parseSidFromWhoami(stdout) {
414
640
  return match ? match[1] : null;
415
641
  }
416
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
+
417
662
  // `startError` is set when the program did not run at all (not found, timed
418
663
  // out): unlike an exit code, that says nothing about the task.
419
- function defaultExecFileImpl(file, args) {
664
+ function defaultExecFileImpl(file, args, { timeout = QUERY_TIMEOUT_MS } = {}) {
420
665
  return new Promise((resolve) => {
421
- execFile(file, args, { windowsHide: true, timeout: QUERY_TIMEOUT_MS }, (error, stdout, stderr) => {
666
+ execFile(file, args, { windowsHide: true, timeout }, (error, stdout, stderr) => {
422
667
  const exitCode = typeof error?.code === 'number' ? error.code : null;
423
668
  resolve({
424
669
  code: error ? (exitCode ?? 1) : 0,
@@ -471,8 +716,10 @@ export function createWindowsBootAutostartService({
471
716
 
472
717
  /**
473
718
  * The task's XML, null when there is no such task, or `{ readError }` when
474
- * schtasks itself could not run: that says nothing about the task, so it
475
- * 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).
476
723
  */
477
724
  async function queryTaskXml(taskName) {
478
725
  let result;
@@ -486,7 +733,64 @@ export function createWindowsBootAutostartService({
486
733
  // The details stay in the log (they can name folders on the host).
487
734
  return { readError: 'schtasks could not be run; the relay log has the details.' };
488
735
  }
489
- 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.' };
490
794
  }
491
795
 
492
796
  function readLauncher() {
@@ -542,11 +846,48 @@ export function createWindowsBootAutostartService({
542
846
  return true;
543
847
  }
544
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;
883
+ }
884
+
545
885
  /** What differs from the generated task and launcher, for a task that runs our launcher. */
546
- function customisationsOf(xmlText) {
886
+ async function customisationsOf(xmlText) {
547
887
  const differences = describeTaskCustomisations(xmlText, {
548
888
  launcherPath: launcherPath(),
549
889
  workingDirectory: oarRoot(),
890
+ user: await currentUser(),
550
891
  });
551
892
  const launcher = readLauncher();
552
893
  if (launcher === null) differences.push('launcher-missing');
@@ -563,27 +904,82 @@ export function createWindowsBootAutostartService({
563
904
  return differences;
564
905
  }
565
906
 
566
- function startupCmdPaths() {
567
- const dir = resolveWindowsStartupDirectory(env, pathImpl);
568
- return [
569
- pathImpl.join(dir, WINDOWS_AUTOSTART_FILENAME),
570
- pathImpl.join(dir, LEGACY_WINDOWS_AUTOSTART_FILENAME),
571
- ];
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;
572
928
  }
573
929
 
574
- function removeStartupCmds() {
575
- let removed = 0;
576
- for (const target of startupCmdPaths()) {
577
- try {
578
- if (fsImpl.existsSync(target)) {
579
- fsImpl.unlinkSync(target);
580
- 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;
581
952
  }
582
- } catch (error) {
583
- 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}`);
584
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 [];
585
982
  }
586
- return removed;
587
983
  }
588
984
 
589
985
  function manualCommandFor(action) {
@@ -611,6 +1007,8 @@ export function createWindowsBootAutostartService({
611
1007
  taskName: BOOT_TASK_NAME,
612
1008
  legacyTaskPresent: false,
613
1009
  launcherPath: null,
1010
+ generated: false,
1011
+ handMade: [],
614
1012
  pendingElevation: null,
615
1013
  lastError: null,
616
1014
  manualCommand: manualCommandFor('enable'),
@@ -618,18 +1016,20 @@ export function createWindowsBootAutostartService({
618
1016
  }
619
1017
  const ownXml = await queryTaskXml(BOOT_TASK_NAME);
620
1018
  const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
621
- 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;
622
1021
  const legacyKnown = typeof legacyXml === 'string';
623
1022
  let taskStatus;
624
- if (readError) taskStatus = 'unknown';
1023
+ if (ownXml?.readError) taskStatus = 'unknown';
625
1024
  else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
626
1025
  else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
627
1026
  // A task that runs our launcher but is not quite the one OAR registers (no
628
1027
  // boot delay, an edited launcher) still is boot mode; it is reported,
629
1028
  // never failed on.
630
- const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
1029
+ const customisations = taskStatus === 'ready' ? await customisationsOf(ownXml) : [];
631
1030
  let launcherPresent = false;
632
1031
  try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
1032
+ const handMade = handMadeEntries({ taskStatus, customisations, legacyTaskPresent: legacyKnown });
633
1033
  return {
634
1034
  supported: true,
635
1035
  platform,
@@ -639,6 +1039,10 @@ export function createWindowsBootAutostartService({
639
1039
  launcherPath: launcherPresent ? launcherPath() : null,
640
1040
  customised: customisations.length > 0,
641
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,
642
1046
  launcherBackupPath,
643
1047
  readError,
644
1048
  pendingElevation: pending ? pending.action : null,
@@ -654,61 +1058,163 @@ export function createWindowsBootAutostartService({
654
1058
  fsImpl.renameSync(temporaryPath, targetPath);
655
1059
  }
656
1060
 
657
- function settlePending(outcome) {
658
- if (!pending) return;
659
- if (pending.timer) clearTimeoutImpl(pending.timer);
660
- pending = null;
661
- lastError = outcome || null;
662
- }
663
-
664
1061
  /**
665
- * The elevated child is powershell running Start-Process -Verb RunAs -Wait:
666
- * the outer process lives exactly as long as the UAC prompt plus the sweep
667
- * 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.
668
1068
  */
669
- function spawnElevated(action, onSettled) {
1069
+ function spawnElevated(action, script, onOutcome) {
670
1070
  const powershell = tool('powershell.exe');
671
1071
  const child = spawnImpl(powershell, [
672
1072
  '-NoProfile',
673
1073
  '-NonInteractive',
674
1074
  '-Command',
675
- [
676
- `$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
677
- `'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
678
- '; exit $p.ExitCode',
679
- ].join(' '),
1075
+ buildElevationCommand({ powershellPath: powershell, script }),
680
1076
  ], {
681
1077
  stdio: ['ignore', 'ignore', 'pipe'],
682
1078
  windowsHide: true,
683
1079
  });
684
1080
  let stderr = '';
685
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
+ };
686
1088
  const timer = setTimeoutImpl(() => {
687
1089
  try { child.kill('SIGTERM'); } catch {}
688
- settlePending(`${action} timed out waiting for the admin confirmation on the PC.`);
689
- onSettled?.(false);
1090
+ settle('failed', 'no answer at the administrator prompt within 2 minutes');
690
1091
  }, ELEVATION_TIMEOUT_MS);
691
1092
  timer?.unref?.();
692
1093
  pending = { action, startedAt: now(), timer, child };
693
- child.on('error', (error) => {
694
- settlePending(`${action} could not request elevation: ${error?.message || error}`);
695
- onSettled?.(false);
696
- });
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}`));
697
1097
  child.on('close', (code) => {
698
- if (!pending) return; // timeout already settled it
699
- if (code === 0) {
700
- settlePending(null);
701
- onSettled?.(true);
702
- } else {
703
- // 1 from Start-Process usually means the UAC prompt was declined.
704
- const detail = normalizeText(stderr).split('\n')[0] || `exit code ${code}`;
705
- settlePending(`${action} was not confirmed (${detail}). Run the command below from an elevated shell instead.`);
706
- onSettled?.(false);
707
- }
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}`));
708
1102
  });
709
1103
  }
710
1104
 
711
- 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 } = {}) {
712
1218
  if (!supported) {
713
1219
  throw new Error('Windows boot autostart is only available on Windows.');
714
1220
  }
@@ -718,51 +1224,101 @@ export function createWindowsBootAutostartService({
718
1224
  if (pending) {
719
1225
  return { ...(await getState()), accepted: false };
720
1226
  }
721
- lastError = null;
722
- const launcher = generatedLauncher();
723
- const previous = readLauncher();
724
- if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
725
- // An edited (or older) launcher is replaced, but kept: one copy, the
726
- // latest replaced one.
727
- const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
728
- try {
729
- fsImpl.writeFileSync(backupPath, previous);
730
- launcherBackupPath = backupPath;
731
- log(`the launcher differed from the generated one; saved it as ${backupPath}`);
732
- } catch (error) {
733
- log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
734
- lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
735
- return { ...(await getState()), accepted: false };
736
- }
1227
+ const before = await getState();
1228
+ if (before.readError) {
1229
+ return { ...before, accepted: false, refused: `the boot task could not be read (${before.readError})` };
737
1230
  }
738
- writeFileAtomic(launcherPath(), generatedLauncher({ coverBytes: previous === null ? 0 : Buffer.byteLength(previous, 'utf8') }));
1231
+ if (before.handMade.length && !replace) {
1232
+ return { ...before, accepted: false, refused: handMadeLines(before.handMade, 'boot').join(' ') };
1233
+ }
1234
+ lastError = null;
739
1235
  const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
740
1236
  const sid = parseSidFromWhoami(who.stdout);
741
1237
  if (!sid) {
742
1238
  lastError = 'Could not determine the current user SID (whoami /user failed).';
743
1239
  return { ...(await getState()), accepted: false };
744
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') }));
745
1260
  writeFileAtomic(xmlPath(), encodeTaskXml(buildBootTaskXml({
746
1261
  sid,
747
1262
  launcherPath: launcherPath(),
748
1263
  packageRoot: pathImpl.resolve(String(packageRoot)),
749
1264
  workingDirectory: oarRoot(),
750
1265
  })));
751
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath(), schtasksPath: tool('schtasks') }));
752
- spawnElevated('enable', (ok) => {
753
- 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);
754
1288
  // Mutual exclusion: boot mode owns startup now, so the sign-in
755
- // entries (either name) must not race it at the next logon.
756
- const removed = removeStartupCmds();
757
- 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);
758
1301
  } else {
759
- log(`enable did not complete: ${lastError}`);
1302
+ lastError = await failureText('enable', outcome === 'ok' ? 'it is not registered afterwards' : detail, copies);
760
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 });
761
1307
  });
762
1308
  return { ...(await getState()), accepted: true };
763
1309
  }
764
1310
 
765
- 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 } = {}) {
766
1322
  if (!supported) {
767
1323
  throw new Error('Windows boot autostart is only available on Windows.');
768
1324
  }
@@ -772,24 +1328,117 @@ export function createWindowsBootAutostartService({
772
1328
  if (pending) {
773
1329
  return { ...(await getState()), accepted: false };
774
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 };
775
1336
  lastError = null;
776
1337
  launcherBackupPath = null;
777
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable', schtasksPath: tool('schtasks') }));
778
- spawnElevated('disable', (ok) => {
779
- if (ok) {
780
- try {
781
- if (fsImpl.existsSync(launcherPath())) fsImpl.unlinkSync(launcherPath());
782
- } catch (error) {
783
- 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());
784
1357
  }
785
- log('disabled');
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;
1397
+ }
1398
+ }
1399
+ if (outcome === 'declined') {
1400
+ left = await dropCopiesAfterDecline(copies, true);
1401
+ lastError = declineText(detail);
786
1402
  } else {
787
- log(`disable did not complete: ${lastError}`);
1403
+ lastError = await failureText('disable', outcome === 'ok' ? 'it is still registered afterwards' : detail, copies);
788
1404
  }
1405
+ log(`disable did not complete: ${lastError}`);
1406
+ onSettled?.(false, { copies: left, declined: outcome === 'declined', reason: detail || null, error: lastError });
789
1407
  });
790
1408
  return { ...(await getState()), accepted: true };
791
1409
  }
792
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
+ }
793
1442
  return {
794
1443
  getState,
795
1444
  requestEnable,