History

Windows: install the background service without Administrator

The Windows `executor install` / `executor service install` always required an elevated PowerShell. It registered the daemon as a boot-triggered Scheduled Task (AtStartup + S4U + RunLevel Highest), and Task Scheduler only lets an Administrator create that shape. macOS (launchd RunAtLoad) and Linux (systemd --user) both install per-user with no elevation, so Windows was the odd one out. Default to a per-user logon task instead: a LogonTrigger + InteractiveToken principal at LeastPrivilege, which a standard user may register for themselves. This matches the launchd/systemd behavior (start at login, unprivileged). The old boot-before-login behavior is preserved behind an explicit `--boot` flag, which still needs an Administrator shell and says so. Register via schtasks.exe rather than the PowerShell *-ScheduledTask cmdlets. Those cmdlets reach Task Scheduler over CIM/DCOM, whose local-activation check fails with Access denied (0x80070005) when the compiled binary spawns the helper on a non-interactive window station; schtasks uses Task Scheduler RPC and has no such dependency. status/uninstall/restart and the orphaned-listener cleanup move off CIM too (schtasks query + netstat/taskkill). A logon task runs in the user's interactive session, so launch the daemon through a hidden wscript shim to avoid flashing a console window on the desktop at every login; the shim waits on the wrapper so the task stays Running and RestartOnFailure is preserved. Verified on Windows Server 2022 as a standard, non-admin user: `--boot` is correctly refused, the default install registers a logon task, the daemon comes up, and /api/health returns 200.

Rhys Sullivan committed 4 months ago

Commit 8bd79751 parent ffa4f703 files · +378 −178Browse files

Unified · Split

3 files
  1. apps/cli/src/main.ts +27 −6
  2. apps/cli/src/service.test.ts +61 −20
  3. apps/cli/src/service.ts +290 −152
apps/cli/src/main.ts+27−6
@@ -2794,6 +2794,16 @@ const servicePortOption = () => · 2793 unmodified lines
2794 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))2794 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2795 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));2795 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
2796 2796
2797const serviceBootOption = () =>
2798 Options.boolean("boot")
2799 .pipe(Options.withDefault(false))
2800 .pipe(
2801 Options.withDescription(
2802 "Windows only: start the daemon at boot before any login (needs an Administrator shell). " +
2803 "Default installs a per-user login task that needs no elevation.",
2804 ),
2805 );
2806
2797const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["platform"]): string => {2807const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["platform"]): string => {
2798 switch (platform) {2808 switch (platform) {
2799 case "darwin":2809 case "darwin":
@@ -2807,7 +2817,7 @@ const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["plat · 7 unmodified lines
2807 }2817 }
2808};2818};
2809 2819
2810const installService = (port: number, commandName: string) =>2820const installService = (port: number, commandName: string, boot = false) =>
2811 Effect.gen(function* () {2821 Effect.gen(function* () {
2812 const command = `${cliPrefix} ${commandName}`;2822 const command = `${cliPrefix} ${commandName}`;
2813 if (isDevMode) {2823 if (isDevMode) {
@@ -2824,7 +2834,12 @@ const installService = (port: number, commandName: string) => · 10 unmodified lines
2824 const backend = getServiceBackend();2834 const backend = getServiceBackend();
2825 if (!backend.automated) {2835 if (!backend.automated) {
2826 // Unsupported platforms surface their manual steps via the install error.2836 // Unsupported platforms surface their manual steps via the install error.
2827 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2837 yield* backend.install({
2838 executablePath: process.execPath,
2839 port,
2840 version: CLI_VERSION,
2841 boot,
2842 });
2828 return;2843 return;
2829 }2844 }
2830 2845
@@ -2881,7 +2896,7 @@ const installService = (port: number, commandName: string) => · 50 unmodified lines
2881 // The unit carries no secret: the supervised daemon mints/loads its bearer2896 // The unit carries no secret: the supervised daemon mints/loads its bearer
2882 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read2897 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2883 // the same file — so reachability is the credential-free /api/health probe.2898 // the same file — so reachability is the credential-free /api/health probe.
2884 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2899 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION, boot });
2885 2900
2886 console.log(`Waiting for Executor to publish its service manifest at ${origin}...`);2901 console.log(`Waiting for Executor to publish its service manifest at ${origin}...`);
2887 const reachable = yield* waitForReachable({2902 const reachable = yield* waitForReachable({
@@ -2904,7 +2919,11 @@ const installService = (port: number, commandName: string) => · 16 unmodified lines
2904 }2919 }
2905 2920
2906 console.log(`Executor is now running as a background service at ${origin}.`);2921 console.log(`Executor is now running as a background service at ${origin}.`);
2907 console.log("It keeps serving after you quit the app and restarts on login.");2922 console.log(
2923 boot && backend.platform === "win32"
2924 ? "It keeps serving after you quit the app and starts at boot, before login."
2925 : "It keeps serving after you quit the app and restarts on login.",
2926 );
2908 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);2927 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2909 });2928 });
2910 2929
@@ -2912,8 +2931,9 @@ const serviceInstallCommand = Command.make( · 1 unmodified line
2912 "install",2931 "install",
2913 {2932 {
2914 port: servicePortOption(),2933 port: servicePortOption(),
2934 boot: serviceBootOption(),
2915 },2935 },
2916 ({ port }) => installService(port, "service install"),2936 ({ port, boot }) => installService(port, "service install", boot),
2917).pipe(2937).pipe(
2918 Command.withDescription("Install and start Executor as an OS-supervised background service"),2938 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2919);2939);
@@ -2996,8 +3016,9 @@ const installCommand = Command.make( · 76 unmodified lines
2996 "install",3016 "install",
2997 {3017 {
2998 port: servicePortOption(),3018 port: servicePortOption(),
3019 boot: serviceBootOption(),
2999 },3020 },
3000 ({ port }) => installService(port, "install"),3021 ({ port, boot }) => installService(port, "install", boot),
3001).pipe(3022).pipe(
3002 Command.withDescription("Install and start Executor as an OS-supervised background service"),3023 Command.withDescription("Install and start Executor as an OS-supervised background service"),
3003);3024);
apps/cli/src/service.test.ts+61−20
@@ -5,9 +5,10 @@ import { · 4 unmodified lines
5 generateLaunchdPlist,5 generateLaunchdPlist,
6 generateSystemdUnit,6 generateSystemdUnit,
7 generateWindowsDaemonWrapper,7 generateWindowsDaemonWrapper,
8 generateWindowsRegisterScript,8 generateWindowsHiddenLauncherVbs,
9 generateWindowsStopExecutorListenersScript,9 generateWindowsTaskXml,
10 getServiceBackend,10 getServiceBackend,
11 parseNetstatListenerPids,
11} from "./service";12} from "./service";
12 13
13describe("service unit generation", () => {14describe("service unit generation", () => {
@@ -124,28 +125,68 @@ describe("service unit generation", () => { · 110 unmodified lines
124 expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);125 expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);
125 });126 });
126 127
127 it("registers a boot-triggered S4U task (the reboot-survival contract)", () => {128 it("defaults to a per-user logon task that needs no elevation", () => {
128 const script = generateWindowsRegisterScript({129 const xml = generateWindowsTaskXml({
129 taskName: "ExecutorDaemon",130 command: "wscript.exe",
130 wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",131 arguments: '"C:\\Users\\x\\.executor\\server-control\\run-daemon.vbs"',
131 userId: "x",132 userId: "HOST\\x",
132 });133 });
133 // S4U + AtStartup = run as the user, at boot, no stored password, no logon.134 // LogonTrigger + InteractiveToken + LeastPrivilege = run as the user at their
134 expect(script).toContain("-LogonType S4U");135 // own logon, unprivileged. A standard user may register this without admin.
135 expect(script).toContain("New-ScheduledTaskTrigger -AtStartup");136 expect(xml).toContain("<LogonTrigger><Enabled>true</Enabled><UserId>HOST\\x</UserId>");
136 expect(script).toContain("-RestartCount 3");137 expect(xml).toContain("<LogonType>InteractiveToken</LogonType>");
137 expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'");138 expect(xml).toContain("<RunLevel>LeastPrivilege</RunLevel>");
138 expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'");139 // The default path must NOT use the admin-only Boot/S4U/Highest knobs.
140 expect(xml).not.toContain("BootTrigger");
141 expect(xml).not.toContain("S4U");
142 expect(xml).not.toContain("HighestAvailable");
143 expect(xml).toContain("<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count>");
144 expect(xml).toContain("<Command>wscript.exe</Command>");
145 expect(xml).toContain("run-daemon.vbs&quot;</Arguments>");
139 });146 });
140 147
141 it("stops orphaned executor.exe listeners without shadowing PowerShell's $PID", () => {148 it("registers a boot-triggered S4U task under --boot (the reboot-survival contract)", () => {
142 const script = generateWindowsStopExecutorListenersScript(4789);149 const xml = generateWindowsTaskXml({
150 command: "wscript.exe",
151 arguments: '"C:\\Users\\x\\.executor\\server-control\\run-daemon.vbs"',
152 userId: "HOST\\x",
153 boot: true,
154 });
155 // BootTrigger + S4U + HighestAvailable = run as the user, at boot, no stored
156 // password, no logon. Task Scheduler only lets an elevated shell create this.
157 expect(xml).toContain("<BootTrigger><Enabled>true</Enabled></BootTrigger>");
158 expect(xml).toContain("<LogonType>S4U</LogonType>");
159 expect(xml).toContain("<RunLevel>HighestAvailable</RunLevel>");
160 expect(xml).not.toContain("LogonTrigger");
161 expect(xml).not.toContain("InteractiveToken");
162 });
163
164 it("hides the daemon console via a wait-and-propagate wscript shim", () => {
165 const vbs = generateWindowsHiddenLauncherVbs(
166 "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",
167 );
168 // Run(cmd, 0, True): 0 = hidden window, True = wait so the task stays Running
169 // and the wrapper's exit code propagates (preserving RestartOnFailure).
170 expect(vbs).toContain(
171 'sh.Run("""C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd""", 0, True)',
172 );
173 expect(vbs).toContain("WScript.Quit rc");
174 });
143 175
144 expect(script).toContain("Get-NetTCPConnection -LocalPort 4789 -State Listen");176 it("parses LISTENING pids for the service port from netstat output", () => {
145 expect(script).toContain("$listenerPid");177 const netstat = [
146 expect(script).not.toContain("foreach ($pid");178 "Active Connections",
147 expect(script).toContain("[string]$process.Name -ieq 'executor.exe'");179 " Proto Local Address Foreign Address State PID",
148 expect(script).toContain('Write-Output ("STOPPED=" + $listenerPid)');180 " TCP 127.0.0.1:4789 0.0.0.0:0 LISTENING 4072",
181 " TCP 127.0.0.1:4789 127.0.0.1:51000 ESTABLISHED 4072",
182 " TCP [::1]:4789 [::]:0 LISTENING 4072",
183 " TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4",
184 " TCP 127.0.0.1:8765 0.0.0.0:0 LISTENING 9999",
185 ].join("\r\n");
186 expect(parseNetstatListenerPids(netstat, 4789)).toEqual([4072]);
187 // A connection in another state on the port must not be treated as a listener.
188 expect(parseNetstatListenerPids(netstat, 445)).toEqual([4]);
189 expect(parseNetstatListenerPids(netstat, 1234)).toEqual([]);
149 });190 });
150});191});
151 192
apps/cli/src/service.ts+290−152
@@ -16,8 +16,14 @@ import { resolveExecutorDataDir } from "./local-server-manifest"; · 15 unmodified lines
16// `<executor> daemon run --foreground --port <p>`, bind loopback, write16// `<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 (Task19// 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
23export const SERVICE_LABEL = "sh.executor.daemon";29export 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 its57// 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/**
555const 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 */
577const 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 and589 * - `boot: false` (DEFAULT, no Administrator needed): a LogonTrigger for this
561 * systemd lingering: the task runs the daemon AS THE USER, at boot, with no590 * user + an InteractiveToken principal at LeastPrivilege. A standard user is
562 * stored password and no interactive logon — verified to survive a real reboot591 * 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 the592 * this installs from an ordinary, non-elevated shell. It is the Windows
564 * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time593 * equivalent of a launchd LaunchAgent with RunAtLoad and `systemd --user`:
565 * out a long-running task". Registering a boot task requires an elevated594 * 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 */
568export const generateWindowsRegisterScript = (options: {609export 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 */
659export 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). */
668const 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. */
672const 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 */
687export 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. Task702 * Stop orphaned Windows `executor.exe` listeners on the service port. Task
584 * Scheduler can report the task as stopped after terminating the `.cmd` action703 * 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 is704 * 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 it705 * 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 */
589export 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). */
605const 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
615export const stopWindowsExecutorListenersOnPort = (710export 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
634const makeWindowsBackend = (): ServiceBackend => ({732const 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 a764 const xmlPath = taskXmlPath(path);
675 // failure to even spawn PowerShell — that would leave the task registered765 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
735const makeUnsupportedBackend = (): ServiceBackend => ({873const makeUnsupportedBackend = (): ServiceBackend => ({
736 platform: "unsupported",874 platform: "unsupported",