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
27942794 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
27952795 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
27962796
2797+const 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+
27972807 const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["platform"]): string => {
27982808 switch (platform) {
27992809 case "darwin":
@@ -2807,7 +2817,7 @@ const serviceManagerName = (platform: ReturnType<typeof getServiceBackend>["plat · 7 unmodified lines
28072817 }
28082818 };
28092819
2810−const installService = (port: number, commandName: string) =>
2820+const installService = (port: number, commandName: string, boot = false) =>
28112821 Effect.gen(function* () {
28122822 const command = `${cliPrefix} ${commandName}`;
28132823 if (isDevMode) {
@@ -2824,7 +2834,12 @@ const installService = (port: number, commandName: string) => · 10 unmodified lines
28242834 const backend = getServiceBackend();
28252835 if (!backend.automated) {
28262836 // 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+ });
28282843 return;
28292844 }
28302845
@@ -2881,7 +2896,7 @@ const installService = (port: number, commandName: string) => · 50 unmodified lines
28812896 // The unit carries no secret: the supervised daemon mints/loads its bearer
28822897 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
28832898 // 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 });
28852900
28862901 console.log(`Waiting for Executor to publish its service manifest at ${origin}...`);
28872902 const reachable = yield* waitForReachable({
@@ -2904,7 +2919,11 @@ const installService = (port: number, commandName: string) => · 16 unmodified lines
29042919 }
29052920
29062921 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+ );
29082927 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
29092928 });
29102929
@@ -2912,8 +2931,9 @@ const serviceInstallCommand = Command.make( · 1 unmodified line
29122931 "install",
29132932 {
29142933 port: servicePortOption(),
2934+ boot: serviceBootOption(),
29152935 },
2916− ({ port }) => installService(port, "service install"),
2936+ ({ port, boot }) => installService(port, "service install", boot),
29172937 ).pipe(
29182938 Command.withDescription("Install and start Executor as an OS-supervised background service"),
29192939 );
@@ -2996,8 +3016,9 @@ const installCommand = Command.make( · 76 unmodified lines
29963016 "install",
29973017 {
29983018 port: servicePortOption(),
3019+ boot: serviceBootOption(),
29993020 },
3000− ({ port }) => installService(port, "install"),
3021+ ({ port, boot }) => installService(port, "install", boot),
30013022 ).pipe(
30023023 Command.withDescription("Install and start Executor as an OS-supervised background service"),
30033024 );
apps/cli/src/service.test.ts+61−20
@@ -5,9 +5,10 @@ import { · 4 unmodified lines
55 generateLaunchdPlist,
66 generateSystemdUnit,
77 generateWindowsDaemonWrapper,
8− generateWindowsRegisterScript,
9− generateWindowsStopExecutorListenersScript,
8+ generateWindowsHiddenLauncherVbs,
9+ generateWindowsTaskXml,
1010 getServiceBackend,
11+ parseNetstatListenerPids,
1112 } from "./service";
1213
1314 describe("service unit generation", () => {
@@ -124,28 +125,68 @@ describe("service unit generation", () => { · 110 unmodified lines
124125 expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);
125126 });
126127
127− it("registers a boot-triggered S4U task (the reboot-survival contract)", () => {
128− const script = generateWindowsRegisterScript({
129− taskName: "ExecutorDaemon",
130− wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",
131− userId: "x",
128+ it("defaults to a per-user logon task that needs no elevation", () => {
129+ const xml = generateWindowsTaskXml({
130+ command: "wscript.exe",
131+ arguments: '"C:\\Users\\x\\.executor\\server-control\\run-daemon.vbs"',
132+ userId: "HOST\\x",
132133 });
133− // S4U + AtStartup = run as the user, at boot, no stored password, no logon.
134− expect(script).toContain("-LogonType S4U");
135− expect(script).toContain("New-ScheduledTaskTrigger -AtStartup");
136− expect(script).toContain("-RestartCount 3");
137− expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'");
138− expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'");
134+ // LogonTrigger + InteractiveToken + LeastPrivilege = run as the user at their
135+ // own logon, unprivileged. A standard user may register this without admin.
136+ expect(xml).toContain("<LogonTrigger><Enabled>true</Enabled><UserId>HOST\\x</UserId>");
137+ expect(xml).toContain("<LogonType>InteractiveToken</LogonType>");
138+ expect(xml).toContain("<RunLevel>LeastPrivilege</RunLevel>");
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>");
139146 });
140147
141− it("stops orphaned executor.exe listeners without shadowing PowerShell's $PID", () => {
142− const script = generateWindowsStopExecutorListenersScript(4789);
148+ it("registers a boot-triggered S4U task under --boot (the reboot-survival contract)", () => {
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+ });
143175
144− expect(script).toContain("Get-NetTCPConnection -LocalPort 4789 -State Listen");
145− expect(script).toContain("$listenerPid");
146− expect(script).not.toContain("foreach ($pid");
147− expect(script).toContain("[string]$process.Name -ieq 'executor.exe'");
148− expect(script).toContain('Write-Output ("STOPPED=" + $listenerPid)');
176+ it("parses LISTENING pids for the service port from netstat output", () => {
177+ const netstat = [
178+ "Active Connections",
179+ " Proto Local Address Foreign Address State PID",
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([]);
149190 });
150191 });
151192
apps/cli/src/service.ts+290−152
@@ -16,8 +16,14 @@ import { resolveExecutorDataDir } from "./local-server-manifest"; · 15 unmodified lines
1616 // `<executor> daemon run --foreground --port <p>`, bind loopback, write
1717 // `server.json`, and get restarted on crash but not on a clean stop.
1818 //
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.
2127 // ---------------------------------------------------------------------------
2228
2329 export const SERVICE_LABEL = "sh.executor.daemon";
@@ -36,6 +42,16 @@ export interface ServiceDescriptor { · 12 unmodified lines
3642 readonly port: number;
3743 /** Installing CLI version, baked in for drift detection on upgrade. */
3844 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;
3955 }
4056
4157 // No secret is part of the descriptor: the supervised daemon mints/loads its
@@ -551,186 +567,308 @@ export const generateWindowsDaemonWrapper = ( · 509 unmodified lines
551567 return ["@echo off", ...setLines, command, ""].join("\r\n");
552568 };
553569
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+};
556582
557583 /**
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"):
559588 *
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.
567608 */
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;
571612 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 =>
573660 [
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+};
581700
582701 /**
583702 * Stop orphaned Windows `executor.exe` listeners on the service port. Task
584703 * 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.
588709 */
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−
615710 export const stopWindowsExecutorListenersOnPort = (
616711 port: number,
617712 ): Effect.Effect<ReadonlyArray<number>, Error> =>
618713 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();
622717 return yield* Effect.fail(
623718 new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`),
624719 );
625720 }
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;
632730 });
633731
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");
652739
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(
656755 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),
667757 );
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+ ),
685818 ),
686819 ),
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";
701850 return {
702851 platform: "win32" as const,
703− registered: false,
704− running: false,
852+ registered: true,
853+ running,
705854 pid: null,
706− detail: ["No ExecutorDaemon scheduled task registered. Run `executor service install`."],
855+ detail: running ? [] : [`Scheduled task registered; current state: ${state}.`],
707856 };
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+};
734872
735873 const makeUnsupportedBackend = (): ServiceBackend => ({
736874 platform: "unsupported",