| @@ -16,8 +16,14 @@ import { resolveExecutorDataDir } from "./local-server-manifest"; · 15 unmodified lines |
| 16 | // `<executor> daemon run --foreground --port <p>`, bind loopback, write | 16 | // `<executor> daemon run --foreground --port <p>`, bind loopback, write |
| 17 | // `server.json`, and get restarted on crash but not on a clean stop. | 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 | 19 | // By default each backend starts the daemon at LOGIN with no elevation: launchd |
| 20 | // Scheduler S4U/AtStartup) are all reboot-survival verified in real VMs. | 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 | export const SERVICE_LABEL = "sh.executor.daemon"; | 29 | export const SERVICE_LABEL = "sh.executor.daemon"; |
| @@ -36,6 +42,16 @@ export interface ServiceDescriptor { · 12 unmodified lines |
| 36 | readonly port: number; | 42 | readonly port: number; |
| 37 | /** Installing CLI version, baked in for drift detection on upgrade. */ | 43 | /** Installing CLI version, baked in for drift detection on upgrade. */ |
| 38 | readonly version: string; | 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 | // No secret is part of the descriptor: the supervised daemon mints/loads its | 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 | return ["@echo off", ...setLines, command, ""].join("\r\n"); | 567 | return ["@echo off", ...setLines, command, ""].join("\r\n"); |
| 552 | }; | 568 | }; |
| 553 | | 569 | |
| 554 | /** Quote a value as a PowerShell single-quoted string literal. */ | 570 | /** |
| 555 | const psSingleQuote = (value: string): string => `'${value.replaceAll("'", "''")}'`; | 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 | 589 | * - `boot: false` (DEFAULT, no Administrator needed): a LogonTrigger for this |
| 561 | * systemd lingering: the task runs the daemon AS THE USER, at boot, with no | 590 | * user + an InteractiveToken principal at LeastPrivilege. A standard user is |
| 562 | * stored password and no interactive logon — verified to survive a real reboot | 591 | * allowed to register a task that runs as themselves at their own logon, so |
| 563 | * with no login on a headless host. RestartCount/RestartInterval supply the | 592 | * this installs from an ordinary, non-elevated shell. It is the Windows |
| 564 | * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time | 593 | * equivalent of a launchd LaunchAgent with RunAtLoad and `systemd --user`: |
| 565 | * out a long-running task". Registering a boot task requires an elevated | 594 | * the daemon comes up when the user logs in, unprivileged. |
| 566 | * (Administrator) shell. | 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: { | 609 | export const generateWindowsTaskXml = (options: { |
| 569 | readonly taskName: string; | 610 | readonly command: string; |
| 570 | readonly wrapperPath: string; | 611 | readonly arguments?: string; |
| 571 | readonly userId: string; | 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)}`, | 661 | 'Set sh = CreateObject("WScript.Shell")', |
| 575 | `$trigger = New-ScheduledTaskTrigger -AtStartup`, | 662 | `rc = sh.Run("""${wrapperPath}""", 0, True)`, |
| 576 | `$principal = New-ScheduledTaskPrincipal -UserId ${psSingleQuote(options.userId)} -LogonType S4U -RunLevel Highest`, | 663 | "WScript.Quit rc", |
| 577 | `$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)`, | 664 | "", |
| 578 | `Register-ScheduledTask -TaskName ${psSingleQuote(options.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`, | 665 | ].join("\r\n"); |
| 579 | `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, | 666 | |
| 580 | ].join("\n"); | 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 | * Stop orphaned Windows `executor.exe` listeners on the service port. Task | 702 | * Stop orphaned Windows `executor.exe` listeners on the service port. Task |
| 584 | * Scheduler can report the task as stopped after terminating the `.cmd` action | 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 | 704 | * while leaving the child `executor.exe` process bound to the port. That process |
| 586 | * healthy enough to satisfy `/api/health`, but `executor web` cannot discover it | 705 | * is healthy enough to satisfy `/api/health`, but `executor web` cannot discover |
| 587 | * once its `server.json` is gone. | 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 | export const stopWindowsExecutorListenersOnPort = ( | 710 | export const stopWindowsExecutorListenersOnPort = ( |
| 616 | port: number, | 711 | port: number, |
| 617 | ): Effect.Effect<ReadonlyArray<number>, Error> => | 712 | ): Effect.Effect<ReadonlyArray<number>, Error> => |
| 618 | Effect.gen(function* () { | 713 | Effect.gen(function* () { |
| 619 | const result = yield* runPowerShell(generateWindowsStopExecutorListenersScript(port)); | 714 | const netstat = yield* runCommand("netstat.exe", ["-ano", "-p", "tcp"]); |
| 620 | if (result.code !== 0) { | 715 | if (netstat.code !== 0) { |
| 621 | const detail = result.stderr.trim() || result.stdout.trim(); | 716 | const detail = netstat.stderr.trim() || netstat.stdout.trim(); |
| 622 | return yield* Effect.fail( | 717 | return yield* Effect.fail( |
| 623 | new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`), | 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) => { | 721 | const stopped: number[] = []; |
| 627 | const match = /^STOPPED=(\d+)\s*$/.exec(line.trim()); | 722 | for (const pid of parseNetstatListenerPids(netstat.stdout, port)) { |
| 628 | if (!match) return []; | 723 | // Confirm the listener really is executor.exe before killing it. |
| 629 | const pid = Number.parseInt(match[1], 10); | 724 | const list = yield* runCommand("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]); |
| 630 | return Number.isInteger(pid) && pid > 0 ? [pid] : []; | 725 | if (!/"executor\.exe"/i.test(list.stdout)) continue; |
| 631 | }); | 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 => ({ | 732 | const makeWindowsBackend = (): ServiceBackend => { |
| 635 | platform: "win32", | 733 | const controlDir = (path: Path.Path): string => |
| 636 | automated: true, | 734 | path.join(resolveExecutorDataDir(path), "server-control"); |
| 637 | install: (descriptor) => | 735 | const taskXmlPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.xml"); |
| 638 | Effect.gen(function* () { | 736 | const wrapperCmdPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.cmd"); |
| 639 | const fs = yield* FileSystem.FileSystem; | 737 | const launcherVbsPath = (path: Path.Path): string => |
| 640 | const path = yield* Path.Path; | 738 | path.join(controlDir(path), "run-daemon.vbs"); |
| 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 | ); | | |
| 652 | | 739 | |
| 653 | const result = yield* runPowerShell( | 740 | return { |
| 654 | generateWindowsRegisterScript({ | 741 | platform: "win32", |
| 655 | taskName: WINDOWS_TASK_NAME, | 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 | wrapperPath, | 755 | wrapperPath, |
| 657 | userId: userInfo().username, | 756 | generateWindowsDaemonWrapper(descriptor, dataDir, logs), |
| 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}`), | | |
| 667 | ); | 757 | ); |
| 668 | } | 758 | |
| 669 | }), | 759 | // Launch the wrapper through a hidden wscript shim so the logon task |
| 670 | uninstall: () => | 760 | // doesn't flash a console window on the interactive desktop each login. |
| 671 | Effect.gen(function* () { | 761 | const vbsPath = launcherVbsPath(path); |
| 672 | const fs = yield* FileSystem.FileSystem; | 762 | yield* fs.writeFileString(vbsPath, generateWindowsHiddenLauncherVbs(wrapperPath)); |
| 673 | const path = yield* Path.Path; | 763 | |
| 674 | // Tolerate "task not found" (idempotent uninstall), but don't hide a | 764 | const xmlPath = taskXmlPath(path); |
| 675 | // failure to even spawn PowerShell — that would leave the task registered | 765 | yield* writeUtf16File( |
| 676 | // while we report success. | 766 | xmlPath, |
| 677 | yield* runPowerShell( | 767 | generateWindowsTaskXml({ |
| 678 | `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + | 768 | command: "wscript.exe", |
| 679 | `Unregister-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`, | 769 | arguments: `"${vbsPath}"`, |
| 680 | ).pipe( | 770 | userId: windowsTaskUserId(), |
| 681 | Effect.tapError((cause) => | 771 | boot: descriptor.boot ?? false, |
| 682 | Effect.sync(() => | 772 | }), |
| 683 | console.warn( | 773 | ); |
| 684 | `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, | 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 | ), | 820 | Effect.ignore, |
| 688 | Effect.ignore, | 821 | ); |
| 689 | ); | 822 | yield* fs.remove(wrapperCmdPath(path), { force: true }); |
| 690 | const control = path.join(resolveExecutorDataDir(path), "server-control"); | 823 | yield* fs.remove(launcherVbsPath(path), { force: true }); |
| 691 | yield* fs.remove(path.join(control, "run-daemon.cmd"), { force: true }); | 824 | yield* fs.remove(taskXmlPath(path), { force: true }); |
| 692 | }), | 825 | }), |
| 693 | status: () => | 826 | status: () => |
| 694 | Effect.gen(function* () { | 827 | Effect.gen(function* () { |
| 695 | const result = yield* runPowerShell( | 828 | const result = yield* runSchtasks([ |
| 696 | `$t = Get-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue; ` + | 829 | "/query", |
| 697 | `if ($null -eq $t) { 'NONE' } else { 'STATE=' + $t.State }`, | 830 | "/tn", |
| 698 | ); | 831 | WINDOWS_TASK_NAME, |
| 699 | const out = result.stdout.trim(); | 832 | "/fo", |
| 700 | if (result.code !== 0 || out === "" || out.includes("NONE")) { | 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 | return { | 850 | return { |
| 702 | platform: "win32" as const, | 851 | platform: "win32" as const, |
| 703 | registered: false, | 852 | registered: true, |
| 704 | running: false, | 853 | running, |
| 705 | pid: null, | 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 | } | 857 | }), |
| 709 | const state = /STATE=(\w+)/.exec(out)?.[1] ?? "Unknown"; | 858 | restart: () => |
| 710 | const running = state === "Running"; | 859 | Effect.gen(function* () { |
| 711 | return { | 860 | yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore); |
| 712 | platform: "win32" as const, | 861 | const run = yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]); |
| 713 | registered: true, | 862 | if (run.code !== 0) { |
| 714 | running, | 863 | return yield* Effect.fail( |
| 715 | pid: null, | 864 | new Error( |
| 716 | detail: running ? [] : [`Scheduled task registered; current state: ${state}.`], | 865 | `Failed to restart ExecutorDaemon task (exit ${run.code}): ${run.stderr.trim() || run.stdout.trim()}`, |
| 717 | }; | 866 | ), |
| 718 | }), | 867 | ); |
| 719 | restart: () => | 868 | } |
| 720 | Effect.gen(function* () { | 869 | }), |
| 721 | const result = yield* runPowerShell( | 870 | }; |
| 722 | `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + | 871 | }; |
| 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 | }); | | |
| 734 | | 872 | |
| 735 | const makeUnsupportedBackend = (): ServiceBackend => ({ | 873 | const makeUnsupportedBackend = (): ServiceBackend => ({ |
| 736 | platform: "unsupported", | 874 | platform: "unsupported", |