| @@ -16,8 +16,14 @@ import { resolveExecutorDataDir } from "./local-server-manifest"; · 15 unmodified lines |
| 16 | 16 | // `<executor> daemon run --foreground --port <p>`, bind loopback, write |
| 17 | 17 | // `server.json`, and get restarted on crash but not on a clean stop. |
| 18 | 18 | // |
| 19 | | −// macOS (launchd), Linux (systemd --user + lingering), and Windows (Task |
| 20 | | −// Scheduler S4U/AtStartup) are all reboot-survival verified in real VMs. |
| 19 | +// By default each backend starts the daemon at LOGIN with no elevation: launchd |
| 20 | +// RunAtLoad, systemd --user, and a Windows per-user logon Scheduled Task. Where |
| 21 | +// the platform can also survive a reboot before any login, that is layered on |
| 22 | +// best-effort (Linux lingering) or behind an explicit, elevation-requiring opt |
| 23 | +// in (Windows `--boot`: a BootTrigger/S4U task). Windows registration goes |
| 24 | +// through `schtasks.exe` (Task Scheduler RPC), not the CIM `*-ScheduledTask` |
| 25 | +// cmdlets, so a non-admin install from the compiled binary is not blocked by a |
| 26 | +// DCOM local-activation denial. |
| 21 | 27 | // --------------------------------------------------------------------------- |
| 22 | 28 | |
| 23 | 29 | export const SERVICE_LABEL = "sh.executor.daemon"; |
| @@ -36,6 +42,16 @@ export interface ServiceDescriptor { · 12 unmodified lines |
| 36 | 42 | readonly port: number; |
| 37 | 43 | /** Installing CLI version, baked in for drift detection on upgrade. */ |
| 38 | 44 | readonly version: string; |
| 45 | + /** |
| 46 | + * Opt in to boot-survival-before-login (Windows only, requires elevation). |
| 47 | + * Default (false) installs a per-user, login-triggered task that needs no |
| 48 | + * Administrator shell, matching launchd RunAtLoad and systemd --user. When |
| 49 | + * true the Windows backend registers an AtStartup/S4U task that runs at boot |
| 50 | + * before any logon, which Task Scheduler only lets an elevated shell create. |
| 51 | + * launchd and systemd ignore this flag (they always start at login, with |
| 52 | + * Linux lingering best-effort upgrading that to boot). |
| 53 | + */ |
| 54 | + readonly boot?: boolean; |
| 39 | 55 | } |
| 40 | 56 | |
| 41 | 57 | // No secret is part of the descriptor: the supervised daemon mints/loads its |
| @@ -551,186 +567,308 @@ export const generateWindowsDaemonWrapper = ( · 509 unmodified lines |
| 551 | 567 | return ["@echo off", ...setLines, command, ""].join("\r\n"); |
| 552 | 568 | }; |
| 553 | 569 | |
| 554 | | −/** Quote a value as a PowerShell single-quoted string literal. */ |
| 555 | | −const psSingleQuote = (value: string): string => `'${value.replaceAll("'", "''")}'`; |
| 570 | +/** |
| 571 | + * The Scheduled Task is registered for this exact account. We pass the |
| 572 | + * fully-qualified `DOMAIN\user` (really `COMPUTERNAME\user` on a standalone box) |
| 573 | + * so Task Scheduler resolves the principal as the caller and lets a non-admin |
| 574 | + * register a task that runs as themselves. The bare username also works, but the |
| 575 | + * qualified form is unambiguous when the machine is domain-joined. |
| 576 | + */ |
| 577 | +const windowsTaskUserId = (): string => { |
| 578 | + const name = userInfo().username; |
| 579 | + const domain = process.env.USERDOMAIN; |
| 580 | + return domain ? `${domain}\\${name}` : name; |
| 581 | +}; |
| 556 | 582 | |
| 557 | 583 | /** |
| 558 | | − * PowerShell that registers the daemon as a boot-triggered Scheduled Task. |
| 584 | + * Render the Task Scheduler task definition (Task Scheduler 1.2 XML, the schema |
| 585 | + * `schtasks /create /xml` consumes). Two shapes, same running contract |
| 586 | + * (RestartOnFailure gives crash-restart; ExecutionTimeLimit=PT0S means "never |
| 587 | + * time out a long-running task"): |
| 559 | 588 | * |
| 560 | | − * LogonType=S4U + AtStartup is the Windows equivalent of launchd RunAtLoad and |
| 561 | | − * systemd lingering: the task runs the daemon AS THE USER, at boot, with no |
| 562 | | − * stored password and no interactive logon — verified to survive a real reboot |
| 563 | | − * with no login on a headless host. RestartCount/RestartInterval supply the |
| 564 | | − * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time |
| 565 | | − * out a long-running task". Registering a boot task requires an elevated |
| 566 | | − * (Administrator) shell. |
| 589 | + * - `boot: false` (DEFAULT, no Administrator needed): a LogonTrigger for this |
| 590 | + * user + an InteractiveToken principal at LeastPrivilege. A standard user is |
| 591 | + * allowed to register a task that runs as themselves at their own logon, so |
| 592 | + * this installs from an ordinary, non-elevated shell. It is the Windows |
| 593 | + * equivalent of a launchd LaunchAgent with RunAtLoad and `systemd --user`: |
| 594 | + * the daemon comes up when the user logs in, unprivileged. |
| 595 | + * |
| 596 | + * - `boot: true` (requires an elevated/Administrator shell): a BootTrigger + an |
| 597 | + * S4U principal at HighestAvailable. Runs the daemon AS THE USER at boot, with |
| 598 | + * no stored password and no interactive logon — survives a real reboot with no |
| 599 | + * login on a headless host. Task Scheduler only lets an elevated shell create |
| 600 | + * a boot/S4U task, which is why this path costs the UAC prompt. |
| 601 | + * |
| 602 | + * We register via `schtasks.exe` rather than the PowerShell `*-ScheduledTask` |
| 603 | + * cmdlets on purpose: those cmdlets reach Task Scheduler over CIM/DCOM, whose |
| 604 | + * local-activation check fails with "Access denied" (0x80070005) when the |
| 605 | + * compiled `executor` binary spawns the helper on a non-interactive window |
| 606 | + * station. `schtasks` talks to the Task Scheduler service over RPC, so it has no |
| 607 | + * such dependency and a non-admin install succeeds. |
| 567 | 608 | */ |
| 568 | | −export const generateWindowsRegisterScript = (options: { |
| 569 | | − readonly taskName: string; |
| 570 | | − readonly wrapperPath: string; |
| 609 | +export const generateWindowsTaskXml = (options: { |
| 610 | + readonly command: string; |
| 611 | + readonly arguments?: string; |
| 571 | 612 | readonly userId: string; |
| 572 | | −}): string => |
| 613 | + readonly boot?: boolean; |
| 614 | +}): string => { |
| 615 | + const user = xmlEscape(options.userId); |
| 616 | + const trigger = options.boot |
| 617 | + ? "<BootTrigger><Enabled>true</Enabled></BootTrigger>" |
| 618 | + : `<LogonTrigger><Enabled>true</Enabled><UserId>${user}</UserId></LogonTrigger>`; |
| 619 | + const logonType = options.boot ? "S4U" : "InteractiveToken"; |
| 620 | + const runLevel = options.boot ? "HighestAvailable" : "LeastPrivilege"; |
| 621 | + const action = options.arguments |
| 622 | + ? `<Exec><Command>${xmlEscape(options.command)}</Command><Arguments>${xmlEscape(options.arguments)}</Arguments></Exec>` |
| 623 | + : `<Exec><Command>${xmlEscape(options.command)}</Command></Exec>`; |
| 624 | + return [ |
| 625 | + '<?xml version="1.0" encoding="UTF-16"?>', |
| 626 | + '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">', |
| 627 | + " <RegistrationInfo><Description>Executor supervised daemon</Description></RegistrationInfo>", |
| 628 | + ` <Triggers>${trigger}</Triggers>`, |
| 629 | + " <Principals>", |
| 630 | + ` <Principal id="Author"><UserId>${user}</UserId><LogonType>${logonType}</LogonType><RunLevel>${runLevel}</RunLevel></Principal>`, |
| 631 | + " </Principals>", |
| 632 | + " <Settings>", |
| 633 | + " <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>", |
| 634 | + " <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>", |
| 635 | + " <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>", |
| 636 | + " <StartWhenAvailable>true</StartWhenAvailable>", |
| 637 | + " <RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>", |
| 638 | + " <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>", |
| 639 | + " <Enabled>true</Enabled>", |
| 640 | + " </Settings>", |
| 641 | + ' <Actions Context="Author">', |
| 642 | + ` ${action}`, |
| 643 | + " </Actions>", |
| 644 | + "</Task>", |
| 645 | + "", |
| 646 | + ].join("\r\n"); |
| 647 | +}; |
| 648 | + |
| 649 | +/** |
| 650 | + * A tiny VBScript shim the task runs via `wscript.exe`. The default (logon) task |
| 651 | + * runs in the user's interactive session, so pointing its action straight at the |
| 652 | + * `.cmd` wrapper would flash a console window on the desktop at every login. The |
| 653 | + * shim launches the wrapper with a hidden window (`Run(cmd, 0, True)`) and waits |
| 654 | + * for it, so: no visible window, and `wscript` stays alive for the daemon's |
| 655 | + * lifetime, which keeps the task reported as Running and preserves RestartOnFailure |
| 656 | + * (the wrapper's exit code propagates out). Output still flows to daemon.log via |
| 657 | + * the wrapper's own redirection. |
| 658 | + */ |
| 659 | +export const generateWindowsHiddenLauncherVbs = (wrapperPath: string): string => |
| 573 | 660 | [ |
| 574 | | − `$action = New-ScheduledTaskAction -Execute ${psSingleQuote(options.wrapperPath)}`, |
| 575 | | − `$trigger = New-ScheduledTaskTrigger -AtStartup`, |
| 576 | | − `$principal = New-ScheduledTaskPrincipal -UserId ${psSingleQuote(options.userId)} -LogonType S4U -RunLevel Highest`, |
| 577 | | − `$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)`, |
| 578 | | − `Register-ScheduledTask -TaskName ${psSingleQuote(options.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`, |
| 579 | | − `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, |
| 580 | | − ].join("\n"); |
| 661 | + 'Set sh = CreateObject("WScript.Shell")', |
| 662 | + `rc = sh.Run("""${wrapperPath}""", 0, True)`, |
| 663 | + "WScript.Quit rc", |
| 664 | + "", |
| 665 | + ].join("\r\n"); |
| 666 | + |
| 667 | +/** Run schtasks.exe, capturing (stdout, stderr, code). */ |
| 668 | +const runSchtasks = (args: ReadonlyArray<string>): Effect.Effect<CommandResult, Error> => |
| 669 | + runCommand("schtasks.exe", args); |
| 670 | + |
| 671 | +/** Write a UTF-16LE (BOM-prefixed) file — the encoding schtasks expects for XML. */ |
| 672 | +const writeUtf16File = ( |
| 673 | + path: string, |
| 674 | + contents: string, |
| 675 | +): Effect.Effect<void, Error | PlatformError, FileSystem.FileSystem> => |
| 676 | + Effect.gen(function* () { |
| 677 | + const fs = yield* FileSystem.FileSystem; |
| 678 | + const bytes = Buffer.from(`${contents}`, "utf16le"); |
| 679 | + yield* fs.writeFile(path, new Uint8Array(bytes)); |
| 680 | + }); |
| 681 | + |
| 682 | +/** |
| 683 | + * Parse `netstat -ano` output for the PIDs LISTENING on `port`. Pure, so it can |
| 684 | + * be unit-tested without a live socket. Matches both IPv4 (`127.0.0.1:PORT`) and |
| 685 | + * IPv6 (`[::1]:PORT`) local endpoints in the LISTENING state. |
| 686 | + */ |
| 687 | +export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray<number> => { |
| 688 | + const pids = new Set<number>(); |
| 689 | + for (const line of output.split(/\r?\n/)) { |
| 690 | + const cols = line.trim().split(/\s+/); |
| 691 | + // TCP <local> <remote> LISTENING <pid> |
| 692 | + if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; |
| 693 | + if (cols[3].toUpperCase() !== "LISTENING") continue; |
| 694 | + if (!cols[1].endsWith(`:${port}`)) continue; |
| 695 | + const pid = Number.parseInt(cols[4], 10); |
| 696 | + if (Number.isInteger(pid) && pid > 0) pids.add(pid); |
| 697 | + } |
| 698 | + return [...pids]; |
| 699 | +}; |
| 581 | 700 | |
| 582 | 701 | /** |
| 583 | 702 | * Stop orphaned Windows `executor.exe` listeners on the service port. Task |
| 584 | 703 | * Scheduler can report the task as stopped after terminating the `.cmd` action |
| 585 | | − * while leaving the child `executor.exe` process bound to 4789. That process is |
| 586 | | − * healthy enough to satisfy `/api/health`, but `executor web` cannot discover it |
| 587 | | − * once its `server.json` is gone. |
| 704 | + * while leaving the child `executor.exe` process bound to the port. That process |
| 705 | + * is healthy enough to satisfy `/api/health`, but `executor web` cannot discover |
| 706 | + * it once its `server.json` is gone. We use netstat/tasklist/taskkill (RPC/Win32 |
| 707 | + * tools) rather than the CIM `Get-NetTCPConnection`/`Get-CimInstance` cmdlets so |
| 708 | + * this works from the compiled binary without a DCOM local-activation grant. |
| 588 | 709 | */ |
| 589 | | −export const generateWindowsStopExecutorListenersScript = (port: number): string => |
| 590 | | − [ |
| 591 | | − `$connections = @(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue)`, |
| 592 | | − `$listenerPids = @($connections | Select-Object -ExpandProperty OwningProcess -Unique)`, |
| 593 | | − `foreach ($listenerPid in $listenerPids) {`, |
| 594 | | − ` $process = Get-CimInstance Win32_Process -Filter "ProcessId=$listenerPid" -ErrorAction SilentlyContinue`, |
| 595 | | − ` if ($null -eq $process) { continue }`, |
| 596 | | − ` $pathName = if ($process.ExecutablePath) { [System.IO.Path]::GetFileName([string]$process.ExecutablePath) } else { '' }`, |
| 597 | | − ` if ([string]$process.Name -ieq 'executor.exe' -or $pathName -ieq 'executor.exe') {`, |
| 598 | | − ` Stop-Process -Id $listenerPid -Force -ErrorAction SilentlyContinue`, |
| 599 | | − ` Write-Output ("STOPPED=" + $listenerPid)`, |
| 600 | | − ` }`, |
| 601 | | − `}`, |
| 602 | | − ].join("\n"); |
| 603 | | − |
| 604 | | −/** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ |
| 605 | | −const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> => |
| 606 | | − runCommand("powershell.exe", [ |
| 607 | | − "-NoProfile", |
| 608 | | − "-NonInteractive", |
| 609 | | − "-ExecutionPolicy", |
| 610 | | − "Bypass", |
| 611 | | − "-EncodedCommand", |
| 612 | | − Buffer.from(script, "utf16le").toString("base64"), |
| 613 | | − ]); |
| 614 | | − |
| 615 | 710 | export const stopWindowsExecutorListenersOnPort = ( |
| 616 | 711 | port: number, |
| 617 | 712 | ): Effect.Effect<ReadonlyArray<number>, Error> => |
| 618 | 713 | Effect.gen(function* () { |
| 619 | | − const result = yield* runPowerShell(generateWindowsStopExecutorListenersScript(port)); |
| 620 | | − if (result.code !== 0) { |
| 621 | | − const detail = result.stderr.trim() || result.stdout.trim(); |
| 714 | + const netstat = yield* runCommand("netstat.exe", ["-ano", "-p", "tcp"]); |
| 715 | + if (netstat.code !== 0) { |
| 716 | + const detail = netstat.stderr.trim() || netstat.stdout.trim(); |
| 622 | 717 | return yield* Effect.fail( |
| 623 | 718 | new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`), |
| 624 | 719 | ); |
| 625 | 720 | } |
| 626 | | − return result.stdout.split(/\r?\n/).flatMap((line) => { |
| 627 | | − const match = /^STOPPED=(\d+)\s*$/.exec(line.trim()); |
| 628 | | − if (!match) return []; |
| 629 | | − const pid = Number.parseInt(match[1], 10); |
| 630 | | − return Number.isInteger(pid) && pid > 0 ? [pid] : []; |
| 631 | | − }); |
| 721 | + const stopped: number[] = []; |
| 722 | + for (const pid of parseNetstatListenerPids(netstat.stdout, port)) { |
| 723 | + // Confirm the listener really is executor.exe before killing it. |
| 724 | + const list = yield* runCommand("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]); |
| 725 | + if (!/"executor\.exe"/i.test(list.stdout)) continue; |
| 726 | + const kill = yield* runCommand("taskkill.exe", ["/PID", String(pid), "/F"]); |
| 727 | + if (kill.code === 0) stopped.push(pid); |
| 728 | + } |
| 729 | + return stopped; |
| 632 | 730 | }); |
| 633 | 731 | |
| 634 | | −const makeWindowsBackend = (): ServiceBackend => ({ |
| 635 | | − platform: "win32", |
| 636 | | − automated: true, |
| 637 | | − install: (descriptor) => |
| 638 | | − Effect.gen(function* () { |
| 639 | | − const fs = yield* FileSystem.FileSystem; |
| 640 | | − const path = yield* Path.Path; |
| 641 | | − const dataDir = resolveExecutorDataDir(path); |
| 642 | | − const logs = serviceLogDir(path); |
| 643 | | − const control = path.join(dataDir, "server-control"); |
| 644 | | − yield* fs.makeDirectory(logs, { recursive: true }); |
| 645 | | − yield* fs.makeDirectory(control, { recursive: true }); |
| 646 | | − |
| 647 | | − const wrapperPath = path.join(control, "run-daemon.cmd"); |
| 648 | | − yield* fs.writeFileString( |
| 649 | | − wrapperPath, |
| 650 | | − generateWindowsDaemonWrapper(descriptor, dataDir, logs), |
| 651 | | − ); |
| 732 | +const makeWindowsBackend = (): ServiceBackend => { |
| 733 | + const controlDir = (path: Path.Path): string => |
| 734 | + path.join(resolveExecutorDataDir(path), "server-control"); |
| 735 | + const taskXmlPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.xml"); |
| 736 | + const wrapperCmdPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.cmd"); |
| 737 | + const launcherVbsPath = (path: Path.Path): string => |
| 738 | + path.join(controlDir(path), "run-daemon.vbs"); |
| 652 | 739 | |
| 653 | | − const result = yield* runPowerShell( |
| 654 | | − generateWindowsRegisterScript({ |
| 655 | | − taskName: WINDOWS_TASK_NAME, |
| 740 | + return { |
| 741 | + platform: "win32", |
| 742 | + automated: true, |
| 743 | + install: (descriptor) => |
| 744 | + Effect.gen(function* () { |
| 745 | + const fs = yield* FileSystem.FileSystem; |
| 746 | + const path = yield* Path.Path; |
| 747 | + const dataDir = resolveExecutorDataDir(path); |
| 748 | + const logs = serviceLogDir(path); |
| 749 | + const control = path.join(dataDir, "server-control"); |
| 750 | + yield* fs.makeDirectory(logs, { recursive: true }); |
| 751 | + yield* fs.makeDirectory(control, { recursive: true }); |
| 752 | + |
| 753 | + const wrapperPath = wrapperCmdPath(path); |
| 754 | + yield* fs.writeFileString( |
| 656 | 755 | wrapperPath, |
| 657 | | − userId: userInfo().username, |
| 658 | | − }), |
| 659 | | − ); |
| 660 | | − if (result.code !== 0) { |
| 661 | | − const detail = result.stderr.trim() || result.stdout.trim(); |
| 662 | | − const hint = /denied|0x80070005|administrator|elevat/i.test(detail) |
| 663 | | − ? " Run `executor service install` from an Administrator PowerShell." |
| 664 | | − : ""; |
| 665 | | − return yield* Effect.fail( |
| 666 | | − new Error(`Register-ScheduledTask failed (exit ${result.code}): ${detail}.${hint}`), |
| 756 | + generateWindowsDaemonWrapper(descriptor, dataDir, logs), |
| 667 | 757 | ); |
| 668 | | − } |
| 669 | | − }), |
| 670 | | − uninstall: () => |
| 671 | | − Effect.gen(function* () { |
| 672 | | − const fs = yield* FileSystem.FileSystem; |
| 673 | | − const path = yield* Path.Path; |
| 674 | | − // Tolerate "task not found" (idempotent uninstall), but don't hide a |
| 675 | | − // failure to even spawn PowerShell — that would leave the task registered |
| 676 | | − // while we report success. |
| 677 | | − yield* runPowerShell( |
| 678 | | − `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + |
| 679 | | − `Unregister-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`, |
| 680 | | − ).pipe( |
| 681 | | − Effect.tapError((cause) => |
| 682 | | − Effect.sync(() => |
| 683 | | − console.warn( |
| 684 | | − `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, |
| 758 | + |
| 759 | + // Launch the wrapper through a hidden wscript shim so the logon task |
| 760 | + // doesn't flash a console window on the interactive desktop each login. |
| 761 | + const vbsPath = launcherVbsPath(path); |
| 762 | + yield* fs.writeFileString(vbsPath, generateWindowsHiddenLauncherVbs(wrapperPath)); |
| 763 | + |
| 764 | + const xmlPath = taskXmlPath(path); |
| 765 | + yield* writeUtf16File( |
| 766 | + xmlPath, |
| 767 | + generateWindowsTaskXml({ |
| 768 | + command: "wscript.exe", |
| 769 | + arguments: `"${vbsPath}"`, |
| 770 | + userId: windowsTaskUserId(), |
| 771 | + boot: descriptor.boot ?? false, |
| 772 | + }), |
| 773 | + ); |
| 774 | + |
| 775 | + const create = yield* runSchtasks([ |
| 776 | + "/create", |
| 777 | + "/tn", |
| 778 | + WINDOWS_TASK_NAME, |
| 779 | + "/xml", |
| 780 | + xmlPath, |
| 781 | + "/f", |
| 782 | + ]); |
| 783 | + if (create.code !== 0) { |
| 784 | + // schtasks ends its messages with a period; drop it so the assembled |
| 785 | + // sentence doesn't read "denied.. <hint>". |
| 786 | + const detail = (create.stderr.trim() || create.stdout.trim()).replace(/\.\s*$/, ""); |
| 787 | + // The default (logon) task needs no elevation. Only the --boot path |
| 788 | + // registers a boot/S4U task, which Task Scheduler refuses without |
| 789 | + // Administrator — so point access-denial at the relevant fix. |
| 790 | + const hint = /denied|0x80070005|administrator|elevat/i.test(detail) |
| 791 | + ? descriptor.boot |
| 792 | + ? " `--boot` registers a boot task, which needs an Administrator shell. Re-run elevated, or drop `--boot` to install a no-elevation login task." |
| 793 | + : " Run `executor service install` from an Administrator shell." |
| 794 | + : ""; |
| 795 | + return yield* Effect.fail( |
| 796 | + new Error(`schtasks /create failed (exit ${create.code}): ${detail}.${hint}`), |
| 797 | + ); |
| 798 | + } |
| 799 | + |
| 800 | + // Bring it up now (the other backends start on install too). A logon task |
| 801 | + // /run launches in the current interactive session; tolerate a non-zero |
| 802 | + // here (e.g. no interactive session yet) since the trigger still fires on |
| 803 | + // the next logon and `service status` reports the registered task. |
| 804 | + yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); |
| 805 | + }), |
| 806 | + uninstall: () => |
| 807 | + Effect.gen(function* () { |
| 808 | + const fs = yield* FileSystem.FileSystem; |
| 809 | + const path = yield* Path.Path; |
| 810 | + // Idempotent: tolerate "task not found" but warn if schtasks can't run. |
| 811 | + yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); |
| 812 | + yield* runSchtasks(["/delete", "/tn", WINDOWS_TASK_NAME, "/f"]).pipe( |
| 813 | + Effect.tapError((cause) => |
| 814 | + Effect.sync(() => |
| 815 | + console.warn( |
| 816 | + `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, |
| 817 | + ), |
| 685 | 818 | ), |
| 686 | 819 | ), |
| 687 | | − ), |
| 688 | | − Effect.ignore, |
| 689 | | − ); |
| 690 | | − const control = path.join(resolveExecutorDataDir(path), "server-control"); |
| 691 | | − yield* fs.remove(path.join(control, "run-daemon.cmd"), { force: true }); |
| 692 | | − }), |
| 693 | | − status: () => |
| 694 | | − Effect.gen(function* () { |
| 695 | | − const result = yield* runPowerShell( |
| 696 | | − `$t = Get-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue; ` + |
| 697 | | − `if ($null -eq $t) { 'NONE' } else { 'STATE=' + $t.State }`, |
| 698 | | − ); |
| 699 | | − const out = result.stdout.trim(); |
| 700 | | − if (result.code !== 0 || out === "" || out.includes("NONE")) { |
| 820 | + Effect.ignore, |
| 821 | + ); |
| 822 | + yield* fs.remove(wrapperCmdPath(path), { force: true }); |
| 823 | + yield* fs.remove(launcherVbsPath(path), { force: true }); |
| 824 | + yield* fs.remove(taskXmlPath(path), { force: true }); |
| 825 | + }), |
| 826 | + status: () => |
| 827 | + Effect.gen(function* () { |
| 828 | + const result = yield* runSchtasks([ |
| 829 | + "/query", |
| 830 | + "/tn", |
| 831 | + WINDOWS_TASK_NAME, |
| 832 | + "/fo", |
| 833 | + "LIST", |
| 834 | + "/v", |
| 835 | + ]); |
| 836 | + if (result.code !== 0) { |
| 837 | + return { |
| 838 | + platform: "win32" as const, |
| 839 | + registered: false, |
| 840 | + running: false, |
| 841 | + pid: null, |
| 842 | + detail: [ |
| 843 | + "No ExecutorDaemon scheduled task registered. Run `executor service install`.", |
| 844 | + ], |
| 845 | + }; |
| 846 | + } |
| 847 | + // schtasks LIST output has a localized "Status:" line: Running/Ready/Disabled. |
| 848 | + const state = /^\s*Status:\s*(\w+)/m.exec(result.stdout)?.[1] ?? "Unknown"; |
| 849 | + const running = state === "Running"; |
| 701 | 850 | return { |
| 702 | 851 | platform: "win32" as const, |
| 703 | | − registered: false, |
| 704 | | − running: false, |
| 852 | + registered: true, |
| 853 | + running, |
| 705 | 854 | pid: null, |
| 706 | | − detail: ["No ExecutorDaemon scheduled task registered. Run `executor service install`."], |
| 855 | + detail: running ? [] : [`Scheduled task registered; current state: ${state}.`], |
| 707 | 856 | }; |
| 708 | | − } |
| 709 | | − const state = /STATE=(\w+)/.exec(out)?.[1] ?? "Unknown"; |
| 710 | | − const running = state === "Running"; |
| 711 | | − return { |
| 712 | | − platform: "win32" as const, |
| 713 | | − registered: true, |
| 714 | | − running, |
| 715 | | − pid: null, |
| 716 | | − detail: running ? [] : [`Scheduled task registered; current state: ${state}.`], |
| 717 | | − }; |
| 718 | | − }), |
| 719 | | − restart: () => |
| 720 | | − Effect.gen(function* () { |
| 721 | | − const result = yield* runPowerShell( |
| 722 | | − `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + |
| 723 | | − `Start-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)}`, |
| 724 | | − ); |
| 725 | | − if (result.code !== 0) { |
| 726 | | − return yield* Effect.fail( |
| 727 | | − new Error( |
| 728 | | − `Failed to restart ExecutorDaemon task (exit ${result.code}): ${result.stderr.trim()}`, |
| 729 | | − ), |
| 730 | | − ); |
| 731 | | − } |
| 732 | | − }), |
| 733 | | −}); |
| 857 | + }), |
| 858 | + restart: () => |
| 859 | + Effect.gen(function* () { |
| 860 | + yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); |
| 861 | + const run = yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]); |
| 862 | + if (run.code !== 0) { |
| 863 | + return yield* Effect.fail( |
| 864 | + new Error( |
| 865 | + `Failed to restart ExecutorDaemon task (exit ${run.code}): ${run.stderr.trim() || run.stdout.trim()}`, |
| 866 | + ), |
| 867 | + ); |
| 868 | + } |
| 869 | + }), |
| 870 | + }; |
| 871 | +}; |
| 734 | 872 | |
| 735 | 873 | const makeUnsupportedBackend = (): ServiceBackend => ({ |
| 736 | 874 | platform: "unsupported", |