-
Notifications
You must be signed in to change notification settings - Fork 177
Expand file tree
/
Copy pathservice.ts
More file actions
929 lines (866 loc) · 37.7 KB
/
Copy pathservice.ts
File metadata and controls
929 lines (866 loc) · 37.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
import { execFile } from "node:child_process";
import { homedir, userInfo } from "node:os";
import { FileSystem, Path } from "effect";
import type { PlatformError } from "effect/PlatformError";
import * as Effect from "effect/Effect";
import { resolveExecutorDataDir } from "./local-server-manifest";
// ---------------------------------------------------------------------------
// OS service backends for the supervised Executor daemon.
//
// The long-lived gateway must outlive the GUI app and survive machine restarts.
// That means the OS service manager — not a foreground process — owns its
// lifecycle: launchd on macOS, systemd --user on Linux, Task Scheduler on
// Windows. Each backend registers the SAME running contract: spawn
// `<executor> daemon run --foreground --port <p>`, bind loopback, write
// `server.json`, and get restarted on crash but not on a clean stop.
//
// By default each backend starts the daemon at LOGIN with no elevation: launchd
// RunAtLoad, systemd --user, and a Windows per-user logon Scheduled Task. Where
// the platform can also survive a reboot before any login, that is layered on
// best-effort (Linux lingering) or behind an explicit, elevation-requiring opt
// in (Windows `--boot`: a BootTrigger/S4U task). Windows registration goes
// through `schtasks.exe` (Task Scheduler RPC), not the CIM `*-ScheduledTask`
// cmdlets, so a non-admin install from the compiled binary is not blocked by a
// DCOM local-activation denial.
// ---------------------------------------------------------------------------
export const SERVICE_LABEL = "sh.executor.daemon";
/**
* The supervised service binds this port by default. It matches the desktop
* connect-card port (4789, not the `executor daemon run` default of 4788) so
* existing desktop MCP-client configs keep resolving. The exact value is
* low-stakes: clients discover the live port from `server.json`.
*/
export const DEFAULT_SERVICE_PORT = 4789;
export interface ServiceDescriptor {
/** Absolute path to the `executor` binary the service should run. */
readonly executablePath: string;
readonly port: number;
/** Installing CLI version, baked in for drift detection on upgrade. */
readonly version: string;
/**
* Opt in to boot-survival-before-login (Windows only, requires elevation).
* Default (false) installs a per-user, login-triggered task that needs no
* Administrator shell, matching launchd RunAtLoad and systemd --user. When
* true the Windows backend registers an AtStartup/S4U task that runs at boot
* before any logon, which Task Scheduler only lets an elevated shell create.
* launchd and systemd ignore this flag (they always start at login, with
* Linux lingering best-effort upgrading that to boot).
*/
readonly boot?: boolean;
}
// No secret is part of the descriptor: the supervised daemon mints/loads its
// bearer token from the 0600 `auth.json` (under EXECUTOR_DATA_DIR) on start, and
// clients read the same file. Keeping the secret out of the plist/unit means
// `launchctl print`/`list` and `systemctl cat` never expose it.
export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported";
export interface ServiceStatus {
readonly platform: ServicePlatform;
/** The OS manager has a unit/plist/task on disk for the service. */
readonly registered: boolean;
/** The OS manager reports the service currently loaded/active. */
readonly running: boolean;
readonly pid: number | null;
/** Extra human-readable lines (e.g. manual steps on unsupported platforms). */
readonly detail: ReadonlyArray<string>;
}
export interface ServiceBackend {
readonly platform: ServicePlatform;
/** True when this backend actually drives the OS manager (vs. printing steps). */
readonly automated: boolean;
readonly install: (
descriptor: ServiceDescriptor,
) => Effect.Effect<void, Error | PlatformError, FileSystem.FileSystem | Path.Path>;
readonly uninstall: () => Effect.Effect<
void,
Error | PlatformError,
FileSystem.FileSystem | Path.Path
>;
readonly status: () => Effect.Effect<
ServiceStatus,
Error | PlatformError,
FileSystem.FileSystem | Path.Path
>;
readonly restart: () => Effect.Effect<void, Error, FileSystem.FileSystem | Path.Path>;
}
// ---------------------------------------------------------------------------
// Process helper — run an OS command and capture (stdout, stderr, exit code).
// Resolves on a non-zero exit so callers can branch; fails only when the
// command itself cannot be spawned (e.g. launchctl missing).
// ---------------------------------------------------------------------------
interface CommandResult {
readonly stdout: string;
readonly stderr: string;
readonly code: number;
}
const runCommand = (
cmd: string,
args: ReadonlyArray<string>,
env?: Record<string, string | undefined>,
): Effect.Effect<CommandResult, Error> =>
Effect.callback<CommandResult, Error>((resume) => {
const options = env
? { encoding: "utf8" as const, env: { ...process.env, ...env } }
: { encoding: "utf8" as const };
execFile(cmd, [...args], options, (error, stdout, stderr) => {
// A string `code` (ENOENT etc.) means the command could not be spawned.
if (error && typeof (error as { code?: unknown }).code === "string") {
resume(
Effect.fail(new Error(`Failed to run \`${cmd}\`: ${(error as { code: string }).code}`)),
);
return;
}
const code =
error && typeof (error as { code?: unknown }).code === "number"
? (error as { code: number }).code
: 0;
resume(Effect.succeed({ stdout: stdout ?? "", stderr: stderr ?? "", code }));
});
});
const currentUid = (): number => {
const getuid = (process as { getuid?: () => number }).getuid;
if (typeof getuid === "function") return getuid.call(process);
return userInfo().uid;
};
const xmlEscape = (value: string): string =>
value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
// ---------------------------------------------------------------------------
// Shared service environment + program args
// ---------------------------------------------------------------------------
const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray<string> => [
descriptor.executablePath,
"daemon",
"run",
"--foreground",
"--port",
String(descriptor.port),
"--hostname",
"127.0.0.1",
];
const serviceEnvironment = (
descriptor: ServiceDescriptor,
dataDir: string,
): Record<string, string> => {
const passThroughKeys = [
"EXECUTOR_CLIENT",
"EXECUTOR_SENTRY_DSN",
"EXECUTOR_SENTRY_RELEASE",
"EXECUTOR_SENTRY_ENVIRONMENT",
"EXECUTOR_RUN_ID",
] as const;
const passThrough = Object.fromEntries(
passThroughKeys.flatMap((key) => {
const value = process.env[key];
return value ? [[key, value] as const] : [];
}),
);
return {
// Marks the process as OS-supervised so the daemon resolves its bearer token
// from the durable 0600 auth.json (the secret is never in the unit itself).
EXECUTOR_SUPERVISED: "1",
// Pin the data/scope dirs explicitly: launchd/systemd give a minimal
// environment and we never want the daemon to fall back to a different home
// or cwd than the user's singleton local service.
EXECUTOR_DATA_DIR: dataDir,
EXECUTOR_SCOPE_DIR: process.env.EXECUTOR_SCOPE_DIR ?? dataDir,
// Stamp the installing version so `service status` can flag drift after an
// upgrade where the unit still points at an older binary path.
EXECUTOR_SERVICE_VERSION: descriptor.version,
// A launchd/systemd unit starts with a bare PATH — without the user's PATH
// the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may
// shell out to. `service install` runs from the user's shell, so its own
// PATH is the right one to bake in. (Reference: opencode shell-env capture.)
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...passThrough,
};
};
// ---------------------------------------------------------------------------
// macOS — launchd LaunchAgent (fully built)
// ---------------------------------------------------------------------------
const launchAgentsDir = (path: Path.Path): string =>
path.join(homedir(), "Library", "LaunchAgents");
const launchdPlistPath = (path: Path.Path): string =>
path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`);
const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
export interface LaunchdPlistOptions {
readonly label: string;
readonly programArguments: ReadonlyArray<string>;
readonly environment: Record<string, string>;
readonly stdoutPath: string;
readonly stderrPath: string;
readonly workingDirectory: string;
}
/**
* Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses
* `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
* exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
* the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background
* keeps it off the foreground scheduler.
*/
export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => {
const programArgs = options.programArguments
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
.join("\n");
const envEntries = Object.entries(options.environment)
.map(
([key, value]) =>
` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${xmlEscape(options.label)}</string>
<key>ProgramArguments</key>
<array>
${programArgs}
</array>
<key>EnvironmentVariables</key>
<dict>
${envEntries}
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>WorkingDirectory</key>
<string>${xmlEscape(options.workingDirectory)}</string>
<key>StandardOutPath</key>
<string>${xmlEscape(options.stdoutPath)}</string>
<key>StandardErrorPath</key>
<string>${xmlEscape(options.stderrPath)}</string>
</dict>
</plist>
`;
};
const parseLaunchctlPid = (printOutput: string): number | null => {
const match = printOutput.match(/\bpid\s*=\s*(\d+)/);
if (!match) return null;
const pid = Number.parseInt(match[1], 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
};
const makeLaunchdBackend = (): ServiceBackend => {
const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`;
return {
platform: "darwin",
automated: true,
install: (descriptor) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const uid = currentUid();
const dataDir = resolveExecutorDataDir(path);
const logs = serviceLogDir(path);
yield* fs.makeDirectory(launchAgentsDir(path), { recursive: true });
yield* fs.makeDirectory(logs, { recursive: true });
const plist = generateLaunchdPlist({
label: SERVICE_LABEL,
programArguments: serviceProgramArguments(descriptor),
environment: serviceEnvironment(descriptor, dataDir),
stdoutPath: path.join(logs, "daemon.log"),
stderrPath: path.join(logs, "daemon.error.log"),
workingDirectory: dataDir,
});
const plistFile = launchdPlistPath(path);
// 0600: the plist is owner-only. It carries no secret — the daemon reads
// the bearer from auth.json at boot — but stays tight regardless.
yield* fs.writeFileString(plistFile, plist, { mode: 0o600 });
// Re-bootstrap cleanly: a stale registration from a prior install would
// make `bootstrap` fail with "service already loaded". `service
// uninstall` also records the label as disabled in launchd's override
// database; clear that before bootstrapping or a reinstall can fail with
// launchctl's generic "Bootstrap failed: 5" error.
yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]);
if (bootstrap.code !== 0) {
return yield* Effect.fail(
new Error(
`launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`,
),
);
}
}),
uninstall: () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const uid = currentUid();
yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
yield* runCommand("launchctl", ["disable", serviceTarget(uid)]).pipe(Effect.ignore);
yield* fs.remove(launchdPlistPath(path), { force: true });
}),
status: () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const uid = currentUid();
const registered = yield* fs.exists(launchdPlistPath(path));
const print = yield* runCommand("launchctl", ["print", serviceTarget(uid)]);
const running = print.code === 0;
return {
platform: "darwin" as const,
registered,
running,
pid: running ? parseLaunchctlPid(print.stdout) : null,
detail: [],
};
}),
restart: () =>
Effect.gen(function* () {
const uid = currentUid();
const result = yield* runCommand("launchctl", ["kickstart", "-k", serviceTarget(uid)]);
if (result.code !== 0) {
return yield* Effect.fail(
new Error(
`launchctl kickstart failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`,
),
);
}
}),
};
};
// ---------------------------------------------------------------------------
// Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM)
// ---------------------------------------------------------------------------
const systemdUnitDir = (path: Path.Path): string =>
path.join(homedir(), ".config", "systemd", "user");
const systemdUnitPath = (path: Path.Path): string =>
path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`);
export interface SystemdUnitOptions {
readonly execStart: ReadonlyArray<string>;
readonly environment: Record<string, string>;
readonly workingDirectory: string;
readonly stdoutPath: string;
readonly stderrPath: string;
}
const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
const systemdQuote = (value: string): string => {
if (SYSTEMD_BARE_VALUE.test(value)) return value;
const escaped = value
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r")
.replaceAll("\t", "\\t");
return `"${escaped}"`;
};
/** Render a systemd --user unit. Pure (snapshot-tested). */
export const generateSystemdUnit = (options: SystemdUnitOptions): string => {
const execStart = options.execStart.map(systemdQuote).join(" ");
const env = Object.entries(options.environment)
.map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
.join("\n");
return `[Unit]
Description=Executor supervised daemon
After=default.target
[Service]
Type=simple
ExecStart=${execStart}
${env}
WorkingDirectory=${systemdQuote(options.workingDirectory)}
StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)}
StandardError=${systemdQuote(`append:${options.stderrPath}`)}
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
`;
};
const makeSystemdBackend = (): ServiceBackend => {
const unitName = `${SERVICE_LABEL}.service`;
return {
platform: "linux",
automated: true,
install: (descriptor) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dataDir = resolveExecutorDataDir(path);
const logs = serviceLogDir(path);
yield* fs.makeDirectory(systemdUnitDir(path), { recursive: true });
yield* fs.makeDirectory(logs, { recursive: true });
const unit = generateSystemdUnit({
execStart: serviceProgramArguments(descriptor),
environment: serviceEnvironment(descriptor, dataDir),
workingDirectory: dataDir,
stdoutPath: path.join(logs, "daemon.log"),
stderrPath: path.join(logs, "daemon.error.log"),
});
yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 });
// `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply
// it if the caller's environment lacks it (e.g. a non-login shell) so
// install is robust regardless of how it was invoked.
const username = userInfo().username;
const sdEnv = {
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
};
yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
const enable = yield* runCommand(
"systemctl",
["--user", "enable", "--now", unitName],
sdEnv,
);
if (enable.code !== 0) {
return yield* Effect.fail(
new Error(
`systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`,
),
);
}
// Enable lingering so the user manager — and this enabled service —
// starts at BOOT, not just on login, so the daemon survives a reboot
// unattended (verified in a real Ubuntu VM via loginctl). Best-effort:
// if the platform needs privilege, the service still works for the
// logged-in case and `service status` flags the missing linger.
yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore);
}),
uninstall: () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sdEnv = {
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
};
yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe(
Effect.ignore,
);
yield* fs.remove(systemdUnitPath(path), { force: true });
yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe(
Effect.ignore,
);
}),
status: () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sdEnv = {
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
};
const registered = yield* fs.exists(systemdUnitPath(path));
const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv);
const running = active.stdout.trim() === "active";
const linger = yield* runCommand("loginctl", [
"show-user",
userInfo().username,
"-p",
"Linger",
"--value",
]);
const lingerOn = linger.stdout.trim() === "yes";
return {
platform: "linux" as const,
registered,
running,
pid: null,
detail: lingerOn
? []
: [
"Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.",
],
};
}),
restart: () =>
Effect.gen(function* () {
const sdEnv = {
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
};
const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv);
if (result.code !== 0) {
return yield* Effect.fail(
new Error(
`systemctl --user restart failed (exit ${result.code}): ${result.stderr.trim()}`,
),
);
}
}),
};
};
// ---------------------------------------------------------------------------
// Windows — Task Scheduler (S4U / AtStartup; reboot-survival verified)
// ---------------------------------------------------------------------------
/** Scheduled Task name registered for the supervised daemon. */
export const WINDOWS_TASK_NAME = "ExecutorDaemon";
/**
* Make a value safe to embed in a cmd.exe `set "KEY=VALUE"` line. A literal `"`
* would close the quoted argument early — in PATH (built from arbitrary
* installer entries) that lets `& cmd &` fragments execute when Task Scheduler
* runs the wrapper at boot, so strip them (a `"` is illegal in a Windows path
* anyway). A literal `%` is re-expanded by cmd at run time against the boot
* environment, silently diverging from the value captured at install; double it
* so the daemon sees exactly what was captured.
*/
export const cmdSetValue = (value: string): string =>
value.replaceAll('"', "").replaceAll("%", "%%");
/**
* The batch wrapper the Scheduled Task executes. Task Scheduler has no field
* for environment variables, so the supervised env (EXECUTOR_SUPERVISED, data
* dir, version, PATH) is baked into the wrapper as `set` lines before it execs
* the daemon. stdout/stderr append to the same log files the other backends
* use. CRLF line endings keep it a well-formed `.cmd`.
*/
export const generateWindowsDaemonWrapper = (
descriptor: ServiceDescriptor,
dataDir: string,
logDir: string,
): string => {
const env = serviceEnvironment(descriptor, dataDir);
const setLines = Object.entries(env).map(([key, value]) => `set "${key}=${cmdSetValue(value)}"`);
const [exe, ...rest] = serviceProgramArguments(descriptor);
const command = `"${exe}" ${rest.join(" ")} 1>> "${logDir}\\daemon.log" 2>> "${logDir}\\daemon.error.log"`;
return ["@echo off", ...setLines, command, ""].join("\r\n");
};
/**
* The Scheduled Task is registered for this exact account. We pass the
* fully-qualified `DOMAIN\user` (really `COMPUTERNAME\user` on a standalone box)
* so Task Scheduler resolves the principal as the caller and lets a non-admin
* register a task that runs as themselves. The bare username also works, but the
* qualified form is unambiguous when the machine is domain-joined.
*/
const windowsTaskUserId = (): string => {
const name = userInfo().username;
const domain = process.env.USERDOMAIN;
return domain ? `${domain}\\${name}` : name;
};
/**
* Render the Task Scheduler task definition (Task Scheduler 1.2 XML, the schema
* `schtasks /create /xml` consumes). Two shapes, same running contract
* (RestartOnFailure gives crash-restart; ExecutionTimeLimit=PT0S means "never
* time out a long-running task"):
*
* - `boot: false` (DEFAULT, no Administrator needed): a LogonTrigger for this
* user + an InteractiveToken principal at LeastPrivilege. A standard user is
* allowed to register a task that runs as themselves at their own logon, so
* this installs from an ordinary, non-elevated shell. It is the Windows
* equivalent of a launchd LaunchAgent with RunAtLoad and `systemd --user`:
* the daemon comes up when the user logs in, unprivileged.
*
* - `boot: true` (requires an elevated/Administrator shell): a BootTrigger + an
* S4U principal at HighestAvailable. Runs the daemon AS THE USER at boot, with
* no stored password and no interactive logon. This survives a real reboot with
* no login on a headless host. Task Scheduler only lets an elevated shell create
* a boot/S4U task, which is why this path costs the UAC prompt.
*
* We register via `schtasks.exe` rather than the PowerShell `*-ScheduledTask`
* cmdlets on purpose: those cmdlets reach Task Scheduler over CIM/DCOM, whose
* local-activation check fails with "Access denied" (0x80070005) when the
* compiled `executor` binary spawns the helper on a non-interactive window
* station. `schtasks` talks to the Task Scheduler service over RPC, so it has no
* such dependency and a non-admin install succeeds.
*/
export const generateWindowsTaskXml = (options: {
readonly command: string;
readonly arguments?: string;
readonly userId: string;
readonly boot?: boolean;
}): string => {
const user = xmlEscape(options.userId);
const trigger = options.boot
? "<BootTrigger><Enabled>true</Enabled></BootTrigger>"
: `<LogonTrigger><Enabled>true</Enabled><UserId>${user}</UserId></LogonTrigger>`;
const logonType = options.boot ? "S4U" : "InteractiveToken";
const runLevel = options.boot ? "HighestAvailable" : "LeastPrivilege";
const action = options.arguments
? `<Exec><Command>${xmlEscape(options.command)}</Command><Arguments>${xmlEscape(options.arguments)}</Arguments></Exec>`
: `<Exec><Command>${xmlEscape(options.command)}</Command></Exec>`;
return [
'<?xml version="1.0" encoding="UTF-16"?>',
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
" <RegistrationInfo><Description>Executor supervised daemon</Description></RegistrationInfo>",
` <Triggers>${trigger}</Triggers>`,
" <Principals>",
` <Principal id="Author"><UserId>${user}</UserId><LogonType>${logonType}</LogonType><RunLevel>${runLevel}</RunLevel></Principal>`,
" </Principals>",
" <Settings>",
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
" <StartWhenAvailable>true</StartWhenAvailable>",
" <RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>",
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>",
" <Enabled>true</Enabled>",
" </Settings>",
' <Actions Context="Author">',
` ${action}`,
" </Actions>",
"</Task>",
"",
].join("\r\n");
};
/**
* A tiny VBScript shim the task runs via `wscript.exe`. The default (logon) task
* runs in the user's interactive session, so pointing its action straight at the
* `.cmd` wrapper would flash a console window on the desktop at every login. The
* shim launches the wrapper with a hidden window (`Run(cmd, 0, True)`) and waits
* for it, so: no visible window, and `wscript` stays alive for the daemon's
* lifetime, which keeps the task reported as Running and preserves RestartOnFailure
* (the wrapper's exit code propagates out). Output still flows to daemon.log via
* the wrapper's own redirection.
*/
export const generateWindowsHiddenLauncherVbs = (wrapperPath: string): string =>
[
'Set sh = CreateObject("WScript.Shell")',
`rc = sh.Run("""${wrapperPath}""", 0, True)`,
"WScript.Quit rc",
"",
].join("\r\n");
/** Run schtasks.exe, capturing (stdout, stderr, code). */
const runSchtasks = (args: ReadonlyArray<string>): Effect.Effect<CommandResult, Error> =>
runCommand("schtasks.exe", args);
/** Write a UTF-16LE (BOM-prefixed) file, the encoding schtasks expects for XML. */
const writeUtf16File = (
path: string,
contents: string,
): Effect.Effect<void, Error | PlatformError, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const bytes = Buffer.from(`${contents}`, "utf16le");
yield* fs.writeFile(path, new Uint8Array(bytes));
});
/**
* SCHED_S_TASK_RUNNING is the Task Scheduler HRESULT a task reports as its "Last
* Result" while it is currently running (0x00041301 == 267009). schtasks prints
* this in the verbose listing as a decimal; some Windows builds/locales print the
* hex form. The numeric code is locale-invariant even though the surrounding
* labels ("Last Result:", "Status:") and the human state word ("Running") are
* translated on a non-English Windows.
*/
const SCHED_S_TASK_RUNNING = 267009;
/**
* Decide whether `schtasks /query /v /fo LIST` reports the task as currently
* running, without depending on the localized "Status:" line. We look for the
* locale-invariant SCHED_S_TASK_RUNNING result code instead. Pure + exported for
* unit tests (including non-English fixtures).
*/
export const parseSchtasksRunning = (verboseListOutput: string): boolean =>
new RegExp(`\\b(?:${SCHED_S_TASK_RUNNING}|0x0*41301)\\b`, "i").test(verboseListOutput);
/**
* Parse `netstat -ano` output for the PIDs listening on `port`. Pure, so it can
* be unit-tested without a live socket. Matches both IPv4 (`127.0.0.1:PORT`) and
* IPv6 (`[::1]:PORT`) local endpoints. A listener is identified by its
* wildcard/zero remote endpoint (`0.0.0.0:0` / `[::]:0`) rather than the state
* column, because that column ("LISTENING") is localized on a non-English
* Windows while the addresses and the `TCP` token are not.
*/
const NETSTAT_LISTENER_REMOTES = new Set(["0.0.0.0:0", "[::]:0", "*:*"]);
export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray<number> => {
const pids = new Set<number>();
for (const line of output.split(/\r?\n/)) {
const cols = line.trim().split(/\s+/);
// TCP <local> <remote(=wildcard when listening)> <state> <pid>
if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;
if (!cols[1].endsWith(`:${port}`)) continue;
if (!NETSTAT_LISTENER_REMOTES.has(cols[2])) continue;
const pid = Number.parseInt(cols[4], 10);
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
}
return [...pids];
};
/**
* Stop orphaned Windows `executor.exe` listeners on the service port. Task
* Scheduler can report the task as stopped after terminating the `.cmd` action
* while leaving the child `executor.exe` process bound to the port. That process
* is healthy enough to satisfy `/api/health`, but `executor web` cannot discover
* it once its `server.json` is gone. We use netstat/tasklist/taskkill (RPC/Win32
* tools) rather than the CIM `Get-NetTCPConnection`/`Get-CimInstance` cmdlets so
* this works from the compiled binary without a DCOM local-activation grant.
*/
export const stopWindowsExecutorListenersOnPort = (
port: number,
): Effect.Effect<ReadonlyArray<number>, Error> =>
Effect.gen(function* () {
const netstat = yield* runCommand("netstat.exe", ["-ano", "-p", "tcp"]);
if (netstat.code !== 0) {
const detail = netstat.stderr.trim() || netstat.stdout.trim();
return yield* Effect.fail(
new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`),
);
}
const stopped: number[] = [];
for (const pid of parseNetstatListenerPids(netstat.stdout, port)) {
// Confirm the listener really is executor.exe before killing it.
const list = yield* runCommand("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
if (!/"executor\.exe"/i.test(list.stdout)) continue;
const kill = yield* runCommand("taskkill.exe", ["/PID", String(pid), "/F"]);
if (kill.code === 0) stopped.push(pid);
}
return stopped;
});
const makeWindowsBackend = (): ServiceBackend => {
const controlDir = (path: Path.Path): string =>
path.join(resolveExecutorDataDir(path), "server-control");
const taskXmlPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.xml");
const wrapperCmdPath = (path: Path.Path): string => path.join(controlDir(path), "run-daemon.cmd");
const launcherVbsPath = (path: Path.Path): string =>
path.join(controlDir(path), "run-daemon.vbs");
return {
platform: "win32",
automated: true,
install: (descriptor) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dataDir = resolveExecutorDataDir(path);
const logs = serviceLogDir(path);
const control = path.join(dataDir, "server-control");
yield* fs.makeDirectory(logs, { recursive: true });
yield* fs.makeDirectory(control, { recursive: true });
const wrapperPath = wrapperCmdPath(path);
yield* fs.writeFileString(
wrapperPath,
generateWindowsDaemonWrapper(descriptor, dataDir, logs),
);
// Launch the wrapper through a hidden wscript shim so the logon task
// doesn't flash a console window on the interactive desktop each login.
const vbsPath = launcherVbsPath(path);
yield* fs.writeFileString(vbsPath, generateWindowsHiddenLauncherVbs(wrapperPath));
const xmlPath = taskXmlPath(path);
yield* writeUtf16File(
xmlPath,
generateWindowsTaskXml({
command: "wscript.exe",
arguments: `"${vbsPath}"`,
userId: windowsTaskUserId(),
boot: descriptor.boot ?? false,
}),
);
const create = yield* runSchtasks([
"/create",
"/tn",
WINDOWS_TASK_NAME,
"/xml",
xmlPath,
"/f",
]);
if (create.code !== 0) {
// schtasks ends its messages with a period; drop it so the assembled
// sentence doesn't read "denied.. <hint>".
const detail = (create.stderr.trim() || create.stdout.trim()).replace(/\.\s*$/, "");
// The default (logon) task needs no elevation. Only the --boot path
// registers a boot/S4U task, which Task Scheduler refuses without
// Administrator, so point access-denial at the relevant fix.
const hint = /denied|0x80070005|administrator|elevat/i.test(detail)
? descriptor.boot
? " `--boot` registers a boot task, which needs an Administrator shell. Re-run elevated, or drop `--boot` to install a no-elevation login task."
: " Run `executor service install` from an Administrator shell."
: "";
return yield* Effect.fail(
new Error(`schtasks /create failed (exit ${create.code}): ${detail}.${hint}`),
);
}
// Bring it up now (the other backends start on install too). A logon task
// /run launches in the current interactive session; tolerate a non-zero
// here (e.g. no interactive session yet) since the trigger still fires on
// the next logon and `service status` reports the registered task.
yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore);
}),
uninstall: () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// Idempotent: tolerate "task not found" but warn if schtasks can't run.
yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore);
yield* runSchtasks(["/delete", "/tn", WINDOWS_TASK_NAME, "/f"]).pipe(
Effect.tapError((cause) =>
Effect.sync(() =>
console.warn(
`Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`,
),
),
),
Effect.ignore,
);
yield* fs.remove(wrapperCmdPath(path), { force: true });
yield* fs.remove(launcherVbsPath(path), { force: true });
yield* fs.remove(taskXmlPath(path), { force: true });
}),
status: () =>
Effect.gen(function* () {
const result = yield* runSchtasks([
"/query",
"/tn",
WINDOWS_TASK_NAME,
"/fo",
"LIST",
"/v",
]);
if (result.code !== 0) {
return {
platform: "win32" as const,
registered: false,
running: false,
pid: null,
detail: [
"No ExecutorDaemon scheduled task registered. Run `executor service install`.",
],
};
}
// Detect "running" via the locale-invariant SCHED_S_TASK_RUNNING result
// code, not the translated "Status: Running" line (which is localized on
// a non-English Windows and would otherwise always read as not-running).
const running = parseSchtasksRunning(result.stdout);
return {
platform: "win32" as const,
registered: true,
running,
pid: null,
detail: running ? [] : ["Scheduled task registered but not currently running."],
};
}),
restart: () =>
Effect.gen(function* () {
yield* runSchtasks(["/end", "/tn", WINDOWS_TASK_NAME]).pipe(Effect.ignore);
const run = yield* runSchtasks(["/run", "/tn", WINDOWS_TASK_NAME]);
if (run.code !== 0) {
return yield* Effect.fail(
new Error(
`Failed to restart ExecutorDaemon task (exit ${run.code}): ${run.stderr.trim() || run.stdout.trim()}`,
),
);
}
}),
};
};
const makeUnsupportedBackend = (): ServiceBackend => ({
platform: "unsupported",
automated: false,
install: () =>
Effect.fail(new Error(`OS service install is not supported on ${process.platform}.`)),
uninstall: () =>
Effect.fail(new Error(`OS service uninstall is not supported on ${process.platform}.`)),
status: () =>
Effect.succeed({
platform: "unsupported" as const,
registered: false,
running: false,
pid: null,
detail: [`OS service management is not supported on ${process.platform}.`],
}),
restart: () =>
Effect.fail(new Error(`OS service restart is not supported on ${process.platform}.`)),
});
/** Select the service backend for the current OS. */
export const getServiceBackend = (platform: NodeJS.Platform = process.platform): ServiceBackend => {
switch (platform) {
case "darwin":
return makeLaunchdBackend();
case "linux":
return makeSystemdBackend();
case "win32":
return makeWindowsBackend();
default:
return makeUnsupportedBackend();
}
};