Skip to content

Commit 8bd7975

Browse files
author
Rhys Sullivan
committed
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.
1 parent ffa4f70 commit 8bd7975

3 files changed

Lines changed: 378 additions & 178 deletions

File tree

apps/cli/src/main.ts

Lines changed: 27 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) {
@@ -2824,7 +2834,12 @@ const installService = (port: number, commandName: string) =>
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) =>
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,16 +2919,21 @@ const installService = (port: number, commandName: string) =>
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

29112930
const serviceInstallCommand = Command.make(
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(
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

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
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", () => {
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

0 commit comments

Comments
 (0)