Skip to content

Commit 0215474

Browse files
RhysSullivanRhys Sullivan
andauthored
Windows: install the background service without Administrator (#1211)
* 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. * Windows service: locale-invariant status + guard --boot off-Windows Address review on non-English Windows: - status() derived "running" from the schtasks `Status: Running` line, but both the label and the word are localized (fr: `État : En cours d'exécution`), so it always read as not-running on a non-English install. Detect via the locale-invariant SCHED_S_TASK_RUNNING result code (267009 / 0x41301) instead, extracted into the pure, tested parseSchtasksRunning(). - parseNetstatListenerPids matched the localized `LISTENING` state word. Identify a listener by its wildcard/zero remote endpoint (0.0.0.0:0 / [::]:0) instead; TCP and addresses are not localized. - `--boot` is documented Windows-only but was silently ignored on macOS/Linux. Warn when it is passed on a non-Windows platform. Tests add fr-FR / de-DE fixtures for both parsers. Verified on the Windows guest against a real running task's schtasks output: the old Status/Running regex regresses to false under fr-FR rendering while the 267009-based check stays correct. --------- Co-authored-by: Rhys Sullivan <rhys@executor.sh>
1 parent fdc0100 commit 0215474

3 files changed

Lines changed: 456 additions & 181 deletions

File tree

apps/cli/src/main.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2794,6 +2794,16 @@ const servicePortOption = () =>
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
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) {
@@ -2822,9 +2832,24 @@ const installService = (port: number, commandName: string) =>
28222832
}
28232833

28242834
const backend = getServiceBackend();
2835+
2836+
// `--boot` only means something on Windows (a boot/S4U Scheduled Task). The
2837+
// launchd/systemd backends silently ignore the descriptor field, so warn
2838+
// rather than let a macOS/Linux caller believe it took effect.
2839+
if (boot && backend.platform !== "win32") {
2840+
console.warn(
2841+
`Note: --boot is a Windows-only option and has no effect on ${process.platform}; installing the standard login-based service.`,
2842+
);
2843+
}
2844+
28252845
if (!backend.automated) {
28262846
// Unsupported platforms surface their manual steps via the install error.
2827-
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2847+
yield* backend.install({
2848+
executablePath: process.execPath,
2849+
port,
2850+
version: CLI_VERSION,
2851+
boot,
2852+
});
28282853
return;
28292854
}
28302855

@@ -2881,7 +2906,7 @@ const installService = (port: number, commandName: string) =>
28812906
// The unit carries no secret: the supervised daemon mints/loads its bearer
28822907
// from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
28832908
// the same file — so reachability is the credential-free /api/health probe.
2884-
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2909+
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION, boot });
28852910

28862911
console.log(`Waiting for Executor to publish its service manifest at ${origin}...`);
28872912
const reachable = yield* waitForReachable({
@@ -2904,16 +2929,21 @@ const installService = (port: number, commandName: string) =>
29042929
}
29052930

29062931
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.");
2932+
console.log(
2933+
boot && backend.platform === "win32"
2934+
? "It keeps serving after you quit the app and starts at boot, before login."
2935+
: "It keeps serving after you quit the app and restarts on login.",
2936+
);
29082937
console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
29092938
});
29102939

29112940
const serviceInstallCommand = Command.make(
29122941
"install",
29132942
{
29142943
port: servicePortOption(),
2944+
boot: serviceBootOption(),
29152945
},
2916-
({ port }) => installService(port, "service install"),
2946+
({ port, boot }) => installService(port, "service install", boot),
29172947
).pipe(
29182948
Command.withDescription("Install and start Executor as an OS-supervised background service"),
29192949
);
@@ -2996,8 +3026,9 @@ const installCommand = Command.make(
29963026
"install",
29973027
{
29983028
port: servicePortOption(),
3029+
boot: serviceBootOption(),
29993030
},
3000-
({ port }) => installService(port, "install"),
3031+
({ port, boot }) => installService(port, "install", boot),
30013032
).pipe(
30023033
Command.withDescription("Install and start Executor as an OS-supervised background service"),
30033034
);

apps/cli/src/service.test.ts

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
generateLaunchdPlist,
66
generateSystemdUnit,
77
generateWindowsDaemonWrapper,
8-
generateWindowsRegisterScript,
9-
generateWindowsStopExecutorListenersScript,
8+
generateWindowsHiddenLauncherVbs,
9+
generateWindowsTaskXml,
1010
getServiceBackend,
11+
parseNetstatListenerPids,
12+
parseSchtasksRunning,
1113
} from "./service";
1214

1315
describe("service unit generation", () => {
@@ -124,28 +126,107 @@ describe("service unit generation", () => {
124126
expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);
125127
});
126128

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",
129+
it("defaults to a per-user logon task that needs no elevation", () => {
130+
const xml = generateWindowsTaskXml({
131+
command: "wscript.exe",
132+
arguments: '"C:\\Users\\x\\.executor\\server-control\\run-daemon.vbs"',
133+
userId: "HOST\\x",
132134
});
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'");
139-
});
140-
141-
it("stops orphaned executor.exe listeners without shadowing PowerShell's $PID", () => {
142-
const script = generateWindowsStopExecutorListenersScript(4789);
143-
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)');
135+
// LogonTrigger + InteractiveToken + LeastPrivilege = run as the user at their
136+
// own logon, unprivileged. A standard user may register this without admin.
137+
expect(xml).toContain("<LogonTrigger><Enabled>true</Enabled><UserId>HOST\\x</UserId>");
138+
expect(xml).toContain("<LogonType>InteractiveToken</LogonType>");
139+
expect(xml).toContain("<RunLevel>LeastPrivilege</RunLevel>");
140+
// The default path must NOT use the admin-only Boot/S4U/Highest knobs.
141+
expect(xml).not.toContain("BootTrigger");
142+
expect(xml).not.toContain("S4U");
143+
expect(xml).not.toContain("HighestAvailable");
144+
expect(xml).toContain("<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count>");
145+
expect(xml).toContain("<Command>wscript.exe</Command>");
146+
expect(xml).toContain("run-daemon.vbs&quot;</Arguments>");
147+
});
148+
149+
it("registers a boot-triggered S4U task under --boot (the reboot-survival contract)", () => {
150+
const xml = generateWindowsTaskXml({
151+
command: "wscript.exe",
152+
arguments: '"C:\\Users\\x\\.executor\\server-control\\run-daemon.vbs"',
153+
userId: "HOST\\x",
154+
boot: true,
155+
});
156+
// BootTrigger + S4U + HighestAvailable = run as the user, at boot, no stored
157+
// password, no logon. Task Scheduler only lets an elevated shell create this.
158+
expect(xml).toContain("<BootTrigger><Enabled>true</Enabled></BootTrigger>");
159+
expect(xml).toContain("<LogonType>S4U</LogonType>");
160+
expect(xml).toContain("<RunLevel>HighestAvailable</RunLevel>");
161+
expect(xml).not.toContain("LogonTrigger");
162+
expect(xml).not.toContain("InteractiveToken");
163+
});
164+
165+
it("hides the daemon console via a wait-and-propagate wscript shim", () => {
166+
const vbs = generateWindowsHiddenLauncherVbs(
167+
"C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",
168+
);
169+
// Run(cmd, 0, True): 0 = hidden window, True = wait so the task stays Running
170+
// and the wrapper's exit code propagates (preserving RestartOnFailure).
171+
expect(vbs).toContain(
172+
'sh.Run("""C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd""", 0, True)',
173+
);
174+
expect(vbs).toContain("WScript.Quit rc");
175+
});
176+
177+
it("parses LISTENING pids for the service port from netstat output", () => {
178+
const netstat = [
179+
"Active Connections",
180+
" Proto Local Address Foreign Address State PID",
181+
" TCP 127.0.0.1:4789 0.0.0.0:0 LISTENING 4072",
182+
" TCP 127.0.0.1:4789 127.0.0.1:51000 ESTABLISHED 4072",
183+
" TCP [::1]:4789 [::]:0 LISTENING 4072",
184+
" TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4",
185+
" TCP 127.0.0.1:8765 0.0.0.0:0 LISTENING 9999",
186+
].join("\r\n");
187+
expect(parseNetstatListenerPids(netstat, 4789)).toEqual([4072]);
188+
// A connection in another state on the port must not be treated as a listener.
189+
expect(parseNetstatListenerPids(netstat, 445)).toEqual([4]);
190+
expect(parseNetstatListenerPids(netstat, 1234)).toEqual([]);
191+
});
192+
193+
it("identifies listeners by wildcard remote, so it survives a localized state column", () => {
194+
// German netstat: the state word is "ABHÖREN"/"HERGESTELLT", but addresses and
195+
// "TCP" are not localized. Listener detection must not depend on the word.
196+
const deNetstat = [
197+
"Aktive Verbindungen",
198+
" Proto Lokale Adresse Remoteadresse Status PID",
199+
" TCP 127.0.0.1:4789 0.0.0.0:0 ABHÖREN 4072",
200+
" TCP 127.0.0.1:4789 127.0.0.1:51000 HERGESTELLT 8000",
201+
].join("\r\n");
202+
expect(parseNetstatListenerPids(deNetstat, 4789)).toEqual([4072]);
203+
});
204+
205+
it("detects a running task via the locale-invariant result code, not the Status word", () => {
206+
// English verbose LIST output for a running task.
207+
const en = [
208+
"TaskName: \\ExecutorDaemon",
209+
"Status: Running",
210+
"Last Result: 267009",
211+
].join("\r\n");
212+
expect(parseSchtasksRunning(en)).toBe(true);
213+
214+
// French Windows: both the label and the status word are translated, but the
215+
// SCHED_S_TASK_RUNNING code (267009 / 0x41301) is the same.
216+
const fr = [
217+
"Nom de tâche: \\ExecutorDaemon",
218+
"État: En cours d'exécution",
219+
"Dernier résultat: 267009",
220+
].join("\r\n");
221+
expect(parseSchtasksRunning(fr)).toBe(true);
222+
223+
// A ready/terminated task (267014 = SCHED_S_TASK_TERMINATED) is not running.
224+
const ready = ["Status: Ready", "Last Result: 267014"].join(
225+
"\r\n",
226+
);
227+
expect(parseSchtasksRunning(ready)).toBe(false);
228+
// Hex form (some builds/locales) is also recognized.
229+
expect(parseSchtasksRunning("Last Result: 0x41301")).toBe(true);
149230
});
150231
});
151232

0 commit comments

Comments
 (0)