History

Add executor service to supervise the local gateway daemon

Rhys Sullivan committed 5 months ago

Commit 21268591 parent 554a7148 files · +1202 −39Browse files

Unified · Split

8 files
  1. apps/cli/src/daemon.test.ts +32 −1
  2. apps/cli/src/daemon.ts +20 −3
  3. apps/cli/src/local-server-manifest.ts +19 −0
  4. apps/cli/src/main.ts +159 −34
  5. apps/cli/src/service.test.ts +148 −0
  6. apps/cli/src/service.ts +688 −0
  7. apps/cli/src/tooling.test.ts +76 −1
  8. apps/cli/src/tooling.ts +60 −0
apps/cli/src/daemon.test.ts+32−1
@@ -3,7 +3,38 @@ import { createServer, type Server } from "node:http"; · 2 unmodified lines
33 import type { AddressInfo } from "node:net";
44 import * as Effect from "effect/Effect";
55
6−import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon";
6+import {
7+ canAutoStartLocalDaemonForHost,
8+ isDevCliEntrypoint,
9+ isExecutorServerReachable,
10+} from "./daemon";
11+
12+describe("isDevCliEntrypoint", () => {
13+ it("treats source entrypoints as dev", () => {
14+ expect(isDevCliEntrypoint("/Users/x/src/executor/apps/cli/src/main.ts")).toBe(true);
15+ expect(isDevCliEntrypoint("/Users/x/dist/main.js")).toBe(true);
16+ });
17+
18+ it("treats compiled single-file binaries as NOT dev (both Unix and Windows)", () => {
19+ // Bun's embedded filesystem: `/$bunfs/...` on Unix, `B:\~BUN\...` on Windows.
20+ // Missing the Windows form made a real `executor.exe` look like a dev
21+ // checkout, so `service install` wrongly refused on Windows.
22+ expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false);
23+ expect(isDevCliEntrypoint("B:/~BUN/root/main.js")).toBe(false);
24+ expect(isDevCliEntrypoint("B:\\~BUN\\root\\main.js")).toBe(false);
25+ });
26+
27+ it("only treats a DRIVE-ROOTED ~BUN as compiled (a ~BUN dir mid-tree stays dev)", () => {
28+ // The Windows bunfs root is `<drive>:\~BUN\...`; a dev checkout that merely
29+ // contains a `~BUN` directory must not be misread as a compiled binary.
30+ expect(isDevCliEntrypoint("/home/user/~BUN/project/src/main.ts")).toBe(true);
31+ expect(isDevCliEntrypoint("C:/Users/dev/~BUN/src/main.ts")).toBe(true);
32+ });
33+
34+ it("is false when no entrypoint is known", () => {
35+ expect(isDevCliEntrypoint(undefined)).toBe(false);
36+ });
37+});
738
839 describe("canAutoStartLocalDaemonForHost", () => {
940 it("allows loopback hosts", () => {
apps/cli/src/daemon.ts+20−3
@@ -53,15 +53,32 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed · 52 unmodified lines
5353 // ---------------------------------------------------------------------------
5454
5555 const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
56−const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/";
5756
5857 export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
5958 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
6059
60+/**
61+ * Bun's compiled-binary embedded filesystem root, drive-rooted on Windows
62+ * (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a
63+ * drive prefix so a dev checkout that merely *contains* a `~BUN` directory
64+ * isn't misread as a compiled binary.
65+ */
66+const WINDOWS_BUNFS_ENTRYPOINT = /^[a-z]:\/~BUN\//i;
67+
68+/**
69+ * Whether the process is running from the dev source (`bun run src/main.ts`)
70+ * rather than a compiled single-file binary. A compiled binary runs from Bun's
71+ * embedded filesystem, whose entrypoint is `/$bunfs/root/main.js` on Unix but
72+ * `B:\~BUN\root\main.js` (argv like `B:/~BUN/root/main.js`) on Windows — match
73+ * BOTH. Missing the Windows form made a real `executor.exe` look like a dev
74+ * checkout, so `service install` refused on Windows. (Found by a real EC2
75+ * Windows test.)
76+ */
6177 export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {
6278 if (!scriptPath) return false;
63− if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false;
64− return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js");
79+ const normalized = scriptPath.replaceAll("\\", "/");
80+ if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false;
81+ return normalized.endsWith(".ts") || normalized.endsWith(".js");
6582 };
6683
6784 export const isExecutorServerReachable = (
apps/cli/src/local-server-manifest.ts+19−0
@@ -76,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: { · 75 unmodified lines
7676 yield* fs.remove(manifestPath, { force: true });
7777 });
7878
79+/**
80+ * Remove the server manifest unconditionally. Used by an OS-supervised daemon
81+ * to reclaim a stale `server.json` left by a previous boot: across a reboot the
82+ * recorded pid is meaningless (pids recycle, so it may now belong to an
83+ * unrelated process), and launchd/systemd already guarantee a single supervised
84+ * instance — so any pre-existing manifest is stale and the supervised daemon
85+ * owns it.
86+ */
87+export const removeLocalServerManifest = (): Effect.Effect<
88+ void,
89+ PlatformError,
90+ FileSystem.FileSystem | Path.Path
91+> =>
92+ Effect.gen(function* () {
93+ const fs = yield* FileSystem.FileSystem;
94+ const path = yield* Path.Path;
95+ yield* fs.remove(localServerManifestPath(path), { force: true });
96+ });
97+
7998 const StartupLockPayload = Schema.Struct({
8099 pid: Schema.Number,
81100 });
apps/cli/src/main.ts+159−34
@@ -100,10 +100,12 @@ import { · 99 unmodified lines
100100 acquireLocalServerStartLock,
101101 readLocalServerManifest,
102102 releaseLocalServerStartLock,
103+ removeLocalServerManifest,
103104 removeLocalServerManifestIfOwnedBy,
104105 resolveExecutorDataDir,
105106 writeLocalServerManifest,
106107 } from "./local-server-manifest";
108+import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service";
107109 import {
108110 defaultCliServerConnectionProfile,
109111 findCliServerConnectionProfile,
@@ -115,7 +117,6 @@ import { · 5 unmodified lines
115117 } from "./server-profile";
116118 import {
117119 buildResumeContentTemplate,
118− buildToolPath,
119120 buildDescribeToolCode,
120121 filterToolPathChildren,
121122 buildInvokeToolCode,
@@ -127,6 +128,7 @@ import { · 5 unmodified lines
127128 inspectToolPath,
128129 normalizeCliErrorText,
129130 parseJsonObjectInput,
131+ resolveToolInvocation,
130132 sanitizeCliOutputText,
131133 shellQuoteArg,
132134 } from "./tooling";
@@ -886,7 +888,19 @@ const runDaemonSession = (input: { · 753 unmodified lines
886888 let token: string | null = null;
887889
888890 try {
889− yield* assertNoOtherActiveLocalServer();
891+ // A supervised daemon (launchd/systemd) is the OS-guaranteed singleton
892+ // — kickstart -k kills the old instance before starting the new — so any
893+ // server.json from a previous boot is stale. Reclaim it rather than
894+ // refusing: across a reboot the recorded pid may have been recycled by
895+ // an unrelated process, which would otherwise make the "is one already
896+ // running?" check treat it as alive-but-unreachable, refuse to start,
897+ // and crash-loop under KeepAlive. (Found by a real reboot test with
898+ // integration data in the DB.)
899+ if (process.env.EXECUTOR_SUPERVISED) {
900+ yield* removeLocalServerManifest().pipe(Effect.ignore);
901+ } else {
902+ yield* assertNoOtherActiveLocalServer();
903+ }
890904
891905 const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
892906
@@ -1572,38 +1586,6 @@ const runCallHelp = ( · 679 unmodified lines
15721586 });
15731587 }).pipe(Effect.mapError(toError));
15741588
1575−const resolveToolInvocation = (input: {
1576− rawPathParts: ReadonlyArray<string>;
1577−}): Effect.Effect<{ path: string; args: Record<string, unknown> }, Error> =>
1578− Effect.gen(function* () {
1579− if (!Array.isArray(input.rawPathParts)) {
1580− return yield* Effect.fail(
1581− new Error("Invalid tool invocation: path parts were not parsed as an array"),
1582− );
1583− }
1584−
1585− const maybeJsonArg = input.rawPathParts.at(-1)?.trim();
1586− const hasInlineJsonArg = maybeJsonArg !== undefined && maybeJsonArg.startsWith("{");
1587− const pathParts = hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts;
1588− const args = hasInlineJsonArg ? yield* parseJsonObjectInput(maybeJsonArg) : {};
1589−
1590− if (pathParts.some((part) => part.trim().startsWith("-"))) {
1591− return yield* Effect.fail(
1592− new Error(
1593− "Tool invocation no longer accepts flags. Use: executor call <path...> '{...json...}'",
1594− ),
1595− );
1596− }
1597−
1598− const path = yield* Effect.try({
1599− try: () => buildToolPath(pathParts),
1600− catch: (cause) =>
1601− cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`),
1602− });
1603−
1604− return { path, args };
1605− });
1606−
16071589 // ---------------------------------------------------------------------------
16081590 // Commands
16091591 // ---------------------------------------------------------------------------
@@ -2000,6 +1982,10 @@ const daemonRunCommand = Command.make( · 390 unmodified lines
20001982 Effect.gen(function* () {
20011983 applyScope(scope);
20021984 if (foreground) {
1985+ // The foreground daemon is the form OS service managers run. Its bearer
1986+ // comes from --auth-token, else the stable token in auth.json (loaded by
1987+ // startServer from EXECUTOR_DATA_DIR) — the supervised unit carries no
1988+ // secret, so the daemon and its clients share the one auth.json token.
20031989 yield* runDaemonSession({
20041990 port,
20051991 hostname,
@@ -2116,6 +2102,144 @@ const mcpCommand = Command.make( · 110 unmodified lines
21162102 }),
21172103 ).pipe(Command.withDescription("Start an MCP server over stdio"));
21182104
2105+// ---------------------------------------------------------------------------
2106+// Service — register the daemon with the OS so it survives app-quit + restart
2107+// ---------------------------------------------------------------------------
2108+
2109+const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2110+
2111+const serviceInstallCommand = Command.make(
2112+ "install",
2113+ {
2114+ port: Options.integer("port")
2115+ .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2116+ .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
2117+ },
2118+ ({ port }) =>
2119+ Effect.gen(function* () {
2120+ if (isDevMode) {
2121+ return yield* Effect.fail(
2122+ new Error(
2123+ [
2124+ "`service install` requires the compiled `executor` binary so the OS can run it directly.",
2125+ `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2126+ ].join("\n"),
2127+ ),
2128+ );
2129+ }
2130+
2131+ const backend = getServiceBackend();
2132+ if (!backend.automated) {
2133+ // Unsupported platforms surface their manual steps via the install error.
2134+ yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2135+ return;
2136+ }
2137+
2138+ // Don't fight an already-running local server against the same data dir
2139+ // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2140+ const active = yield* readActiveLocalServerManifest();
2141+ if (active) {
2142+ const status = yield* backend.status();
2143+ if (status.registered && status.running && active.kind === "cli-daemon") {
2144+ console.log(
2145+ `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
2146+ );
2147+ return;
2148+ }
2149+ return yield* Effect.fail(
2150+ new Error(
2151+ [
2152+ `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2153+ `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2154+ ].join("\n"),
2155+ ),
2156+ );
2157+ }
2158+
2159+ // The unit carries no secret: the supervised daemon mints/loads its bearer
2160+ // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2161+ // the same file — so reachability is the credential-free /api/health probe.
2162+ yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2163+
2164+ const origin = supervisedServiceOrigin(port);
2165+ const reachable = yield* waitForReachable({
2166+ check: isServerReachable(origin),
2167+ timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2168+ intervalMs: DAEMON_BOOT_POLL_MS,
2169+ });
2170+ if (!reachable) {
2171+ return yield* Effect.fail(
2172+ new Error(
2173+ [
2174+ `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2175+ `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2176+ ].join("\n"),
2177+ ),
2178+ );
2179+ }
2180+
2181+ console.log(`Executor is now running as a background service at ${origin}.`);
2182+ console.log("It keeps serving after you quit the app and restarts on login.");
2183+ console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);
2184+ }),
2185+).pipe(
2186+ Command.withDescription("Install and start Executor as an OS-supervised background service"),
2187+);
2188+
2189+const serviceUninstallCommand = Command.make("uninstall", {}, () =>
2190+ Effect.gen(function* () {
2191+ const backend = getServiceBackend();
2192+ yield* backend.uninstall();
2193+ console.log("Executor background service uninstalled.");
2194+ }),
2195+).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));
2196+
2197+const serviceStatusCommand = Command.make("status", {}, () =>
2198+ Effect.gen(function* () {
2199+ const backend = getServiceBackend();
2200+ const status = yield* backend.status();
2201+ // Tolerate a registered-but-unreachable manifest here — status shouldn't throw.
2202+ const active = yield* readActiveLocalServerManifest().pipe(
2203+ Effect.catchCause(() => Effect.succeed(null)),
2204+ );
2205+ console.log(`Platform: ${status.platform}`);
2206+ console.log(`Registered: ${status.registered ? "yes" : "no"}`);
2207+ console.log(
2208+ `Running: ${status.running ? "yes" : "no"}${status.pid ? ` (pid ${status.pid})` : ""}`,
2209+ );
2210+ if (active) {
2211+ console.log(`Serving: ${active.connection.origin} (${active.kind}, pid ${active.pid})`);
2212+ // Version drift: the running daemon was launched by the binary the unit
2213+ // points at. If that differs from this CLI, an upgrade left the unit
2214+ // pointing at an older binary — reinstall to repoint + restart.
2215+ if (active.owner.version && active.owner.version !== CLI_VERSION) {
2216+ console.log(
2217+ `Drift: running ${active.owner.version}, current ${CLI_VERSION} — run \`${cliPrefix} service install\` to upgrade.`,
2218+ );
2219+ }
2220+ }
2221+ for (const line of status.detail) console.log(line);
2222+ }),
2223+).pipe(Command.withDescription("Show the OS-supervised service status"));
2224+
2225+const serviceRestartCommand = Command.make("restart", {}, () =>
2226+ Effect.gen(function* () {
2227+ const backend = getServiceBackend();
2228+ yield* backend.restart();
2229+ console.log("Executor background service restarted.");
2230+ }),
2231+).pipe(Command.withDescription("Restart the OS-supervised background service"));
2232+
2233+const serviceCommand = Command.make("service").pipe(
2234+ Command.withSubcommands([
2235+ serviceInstallCommand,
2236+ serviceUninstallCommand,
2237+ serviceStatusCommand,
2238+ serviceRestartCommand,
2239+ ] as const),
2240+ Command.withDescription("Manage the OS-supervised background service"),
2241+);
2242+
21192243 // ---------------------------------------------------------------------------
21202244 // Root command
21212245 // ---------------------------------------------------------------------------
@@ -2170,6 +2294,7 @@ const root = Command.make("executor").pipe( · 48 unmodified lines
21702294 serverCommand,
21712295 webCommand,
21722296 daemonCommand,
2297+ serviceCommand,
21732298 mcpCommand,
21742299 openCommand,
21752300 ] as const),
apps/cli/src/service.test.ts+148−0
@@ -0,0 +1,148 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ cmdSetValue,
5+ generateLaunchdPlist,
6+ generateSystemdUnit,
7+ generateWindowsDaemonWrapper,
8+ generateWindowsRegisterScript,
9+ getServiceBackend,
10+} from "./service";
11+
12+describe("service unit generation", () => {
13+ const launchdInput = {
14+ label: "sh.executor.daemon",
15+ programArguments: [
16+ "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar",
17+ "daemon",
18+ "run",
19+ "--foreground",
20+ "--port",
21+ "4789",
22+ ],
23+ environment: {
24+ EXECUTOR_SUPERVISED: "1",
25+ EXECUTOR_DATA_DIR: "/Users/x/.executor",
26+ EXECUTOR_SERVICE_VERSION: "1.5.10",
27+ PATH: "/opt/homebrew/bin:/usr/bin",
28+ },
29+ stdoutPath: "/Users/x/.executor/logs/daemon.log",
30+ stderrPath: "/Users/x/.executor/logs/daemon.error.log",
31+ workingDirectory: "/Users/x/.executor",
32+ };
33+
34+ it("renders a launchd plist that restarts on crash but not clean stop", () => {
35+ const plist = generateLaunchdPlist(launchdInput);
36+ expect(plist).toContain('<plist version="1.0">');
37+ expect(plist).toContain("<key>Label</key>");
38+ expect(plist).toContain("<string>sh.executor.daemon</string>");
39+ expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
40+ // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.
41+ expect(plist).toContain("<key>KeepAlive</key>");
42+ expect(plist).toContain("<key>SuccessfulExit</key>");
43+ expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
44+ expect(plist).toContain("<key>ProcessType</key>");
45+ expect(plist).toContain("<string>Background</string>");
46+ expect(plist).toContain("--foreground");
47+ expect(plist).toContain("EXECUTOR_SUPERVISED");
48+ expect(plist).toContain("/Users/x/.executor/logs/daemon.error.log");
49+ });
50+
51+ it("never leaks the auth password into the unit", () => {
52+ const plist = generateLaunchdPlist(launchdInput);
53+ // The secret lives in the 0600 service.key, never in the plist env.
54+ expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");
55+ });
56+
57+ it("xml-escapes environment values", () => {
58+ const plist = generateLaunchdPlist({
59+ ...launchdInput,
60+ environment: { PATH: "a&b<c>\"d'" },
61+ });
62+ expect(plist).toContain("a&amp;b&lt;c&gt;&quot;d&apos;");
63+ expect(plist).not.toMatch(/a&b<c>/);
64+ });
65+
66+ it("renders a systemd --user unit with crash-only restart", () => {
67+ const unit = generateSystemdUnit({
68+ execStart: ["/usr/local/bin/executor", "daemon", "run", "--foreground", "--port", "4789"],
69+ environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" },
70+ workingDirectory: "/home/x/.executor",
71+ stdoutPath: "/home/x/.executor/logs/daemon.log",
72+ stderrPath: "/home/x/.executor/logs/daemon.error.log",
73+ });
74+ expect(unit).toContain("ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789");
75+ expect(unit).toContain("Restart=on-failure");
76+ expect(unit).toContain("WantedBy=default.target");
77+ expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1");
78+ expect(unit).not.toContain("EXECUTOR_AUTH_PASSWORD");
79+ });
80+
81+ it("bakes the supervised env into the Windows wrapper .cmd", () => {
82+ const wrapper = generateWindowsDaemonWrapper(
83+ {
84+ executablePath: "C:\\Program Files\\Executor\\executor.exe",
85+ port: 4789,
86+ version: "1.5.10",
87+ },
88+ "C:\\Users\\x\\.executor",
89+ "C:\\Users\\x\\.executor\\logs",
90+ );
91+ // Task Scheduler can't set env, so it rides as `set` lines in the wrapper.
92+ expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"');
93+ expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"');
94+ expect(wrapper).toContain(
95+ '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789',
96+ );
97+ expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"');
98+ // The secret is never baked into the wrapper — the daemon reads service.key.
99+ expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
100+ });
101+
102+ it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {
103+ // A `"` in PATH would close the `set "PATH=..."` quote early and let a
104+ // `& cmd &` fragment run at boot as the user; strip it (illegal in a path
105+ // anyway). A `%` would re-expand against the boot environment; double it.
106+ expect(cmdSetValue('C:\\a" & evil & "C:\\b')).toBe("C:\\a & evil & C:\\b");
107+ expect(cmdSetValue("C:\\tools\\%LOCALAPPDATA%\\bin")).toBe("C:\\tools\\%%LOCALAPPDATA%%\\bin");
108+ expect(cmdSetValue("C:\\Program Files\\node")).toBe("C:\\Program Files\\node");
109+ // The sanitized value, embedded in a `set` line, can't break out of quotes.
110+ expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);
111+ });
112+
113+ it("registers a boot-triggered S4U task (the reboot-survival contract)", () => {
114+ const script = generateWindowsRegisterScript({
115+ taskName: "ExecutorDaemon",
116+ wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",
117+ userId: "x",
118+ });
119+ // S4U + AtStartup = run as the user, at boot, no stored password, no logon.
120+ expect(script).toContain("-LogonType S4U");
121+ expect(script).toContain("New-ScheduledTaskTrigger -AtStartup");
122+ expect(script).toContain("-RestartCount 3");
123+ expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'");
124+ expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'");
125+ });
126+});
127+
128+describe("service backend dispatch", () => {
129+ it("selects launchd on macOS (automated)", () => {
130+ const backend = getServiceBackend("darwin");
131+ expect(backend.platform).toBe("darwin");
132+ expect(backend.automated).toBe(true);
133+ });
134+
135+ it("selects systemd on linux (automated)", () => {
136+ expect(getServiceBackend("linux").platform).toBe("linux");
137+ });
138+
139+ it("selects Task Scheduler on windows (automated)", () => {
140+ const backend = getServiceBackend("win32");
141+ expect(backend.platform).toBe("win32");
142+ expect(backend.automated).toBe(true);
143+ });
144+
145+ it("falls back to unsupported on other platforms", () => {
146+ expect(getServiceBackend("freebsd").platform).toBe("unsupported");
147+ });
148+});
apps/cli/src/service.ts+688−0
@@ -0,0 +1,688 @@
1+import { execFile } from "node:child_process";
2+import { homedir, userInfo } from "node:os";
3+import { FileSystem, Path } from "effect";
4+import type { PlatformError } from "effect/PlatformError";
5+import * as Effect from "effect/Effect";
6+
7+import { resolveExecutorDataDir } from "./local-server-manifest";
8+
9+// ---------------------------------------------------------------------------
10+// OS service backends for the supervised Executor daemon.
11+//
12+// The long-lived gateway must outlive the GUI app and survive machine restarts.
13+// That means the OS service manager — not a foreground process — owns its
14+// lifecycle: launchd on macOS, systemd --user on Linux, Task Scheduler on
15+// Windows. Each backend registers the SAME running contract: spawn
16+// `<executor> daemon run --foreground --port <p>`, bind loopback, write
17+// `server.json`, and get restarted on crash but not on a clean stop.
18+//
19+// macOS (launchd), Linux (systemd --user + lingering), and Windows (Task
20+// Scheduler S4U/AtStartup) are all reboot-survival verified in real VMs.
21+// ---------------------------------------------------------------------------
22+
23+export const SERVICE_LABEL = "sh.executor.daemon";
24+
25+/**
26+ * The supervised service binds this port by default. It matches the desktop
27+ * connect-card port (4789, not the `executor daemon run` default of 4788) so
28+ * existing desktop MCP-client configs keep resolving. The exact value is
29+ * low-stakes: clients discover the live port from `server.json`.
30+ */
31+export const DEFAULT_SERVICE_PORT = 4789;
32+
33+export interface ServiceDescriptor {
34+ /** Absolute path to the `executor` binary the service should run. */
35+ readonly executablePath: string;
36+ readonly port: number;
37+ /** Installing CLI version, baked in for drift detection on upgrade. */
38+ readonly version: string;
39+}
40+
41+// No secret is part of the descriptor: the supervised daemon mints/loads its
42+// bearer token from the 0600 `auth.json` (under EXECUTOR_DATA_DIR) on start, and
43+// clients read the same file. Keeping the secret out of the plist/unit means
44+// `launchctl print`/`list` and `systemctl cat` never expose it.
45+
46+export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported";
47+
48+export interface ServiceStatus {
49+ readonly platform: ServicePlatform;
50+ /** The OS manager has a unit/plist/task on disk for the service. */
51+ readonly registered: boolean;
52+ /** The OS manager reports the service currently loaded/active. */
53+ readonly running: boolean;
54+ readonly pid: number | null;
55+ /** Extra human-readable lines (e.g. manual steps on unsupported platforms). */
56+ readonly detail: ReadonlyArray<string>;
57+}
58+
59+export interface ServiceBackend {
60+ readonly platform: ServicePlatform;
61+ /** True when this backend actually drives the OS manager (vs. printing steps). */
62+ readonly automated: boolean;
63+ readonly install: (
64+ descriptor: ServiceDescriptor,
65+ ) => Effect.Effect<void, Error | PlatformError, FileSystem.FileSystem | Path.Path>;
66+ readonly uninstall: () => Effect.Effect<
67+ void,
68+ Error | PlatformError,
69+ FileSystem.FileSystem | Path.Path
70+ >;
71+ readonly status: () => Effect.Effect<
72+ ServiceStatus,
73+ Error | PlatformError,
74+ FileSystem.FileSystem | Path.Path
75+ >;
76+ readonly restart: () => Effect.Effect<void, Error, FileSystem.FileSystem | Path.Path>;
77+}
78+
79+// ---------------------------------------------------------------------------
80+// Process helper — run an OS command and capture (stdout, stderr, exit code).
81+// Resolves on a non-zero exit so callers can branch; fails only when the
82+// command itself cannot be spawned (e.g. launchctl missing).
83+// ---------------------------------------------------------------------------
84+
85+interface CommandResult {
86+ readonly stdout: string;
87+ readonly stderr: string;
88+ readonly code: number;
89+}
90+
91+const runCommand = (
92+ cmd: string,
93+ args: ReadonlyArray<string>,
94+ env?: Record<string, string | undefined>,
95+): Effect.Effect<CommandResult, Error> =>
96+ Effect.callback<CommandResult, Error>((resume) => {
97+ const options = env
98+ ? { encoding: "utf8" as const, env: { ...process.env, ...env } }
99+ : { encoding: "utf8" as const };
100+ execFile(cmd, [...args], options, (error, stdout, stderr) => {
101+ // A string `code` (ENOENT etc.) means the command could not be spawned.
102+ if (error && typeof (error as { code?: unknown }).code === "string") {
103+ resume(
104+ Effect.fail(new Error(`Failed to run \`${cmd}\`: ${(error as { code: string }).code}`)),
105+ );
106+ return;
107+ }
108+ const code =
109+ error && typeof (error as { code?: unknown }).code === "number"
110+ ? (error as { code: number }).code
111+ : 0;
112+ resume(Effect.succeed({ stdout: stdout ?? "", stderr: stderr ?? "", code }));
113+ });
114+ });
115+
116+const currentUid = (): number => {
117+ const getuid = (process as { getuid?: () => number }).getuid;
118+ if (typeof getuid === "function") return getuid.call(process);
119+ return userInfo().uid;
120+};
121+
122+const xmlEscape = (value: string): string =>
123+ value
124+ .replaceAll("&", "&amp;")
125+ .replaceAll("<", "&lt;")
126+ .replaceAll(">", "&gt;")
127+ .replaceAll('"', "&quot;")
128+ .replaceAll("'", "&apos;");
129+
130+// ---------------------------------------------------------------------------
131+// Shared service environment + program args
132+// ---------------------------------------------------------------------------
133+
134+const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray<string> => [
135+ descriptor.executablePath,
136+ "daemon",
137+ "run",
138+ "--foreground",
139+ "--port",
140+ String(descriptor.port),
141+];
142+
143+const serviceEnvironment = (
144+ descriptor: ServiceDescriptor,
145+ dataDir: string,
146+): Record<string, string> => ({
147+ // Marks the process as OS-supervised so the daemon resolves its bearer token
148+ // from the durable 0600 auth.json (the secret is never in the unit itself).
149+ EXECUTOR_SUPERVISED: "1",
150+ // Pin the data dir explicitly: launchd/systemd give a minimal environment and
151+ // we never want the daemon to fall back to a different home than the user's.
152+ EXECUTOR_DATA_DIR: dataDir,
153+ // Stamp the installing version so `service status` can flag drift after an
154+ // upgrade where the unit still points at an older binary path.
155+ EXECUTOR_SERVICE_VERSION: descriptor.version,
156+ // A launchd/systemd unit starts with a bare PATH — without the user's PATH
157+ // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may
158+ // shell out to. `service install` runs from the user's shell, so its own
159+ // PATH is the right one to bake in. (Reference: opencode shell-env capture.)
160+ ...(process.env.PATH ? { PATH: process.env.PATH } : {}),
161+});
162+
163+// ---------------------------------------------------------------------------
164+// macOS — launchd LaunchAgent (fully built)
165+// ---------------------------------------------------------------------------
166+
167+const launchAgentsDir = (path: Path.Path): string =>
168+ path.join(homedir(), "Library", "LaunchAgents");
169+
170+const launchdPlistPath = (path: Path.Path): string =>
171+ path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`);
172+
173+const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
174+
175+export interface LaunchdPlistOptions {
176+ readonly label: string;
177+ readonly programArguments: ReadonlyArray<string>;
178+ readonly environment: Record<string, string>;
179+ readonly stdoutPath: string;
180+ readonly stderrPath: string;
181+ readonly workingDirectory: string;
182+}
183+
184+/**
185+ * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses
186+ * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
187+ * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
188+ * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background
189+ * keeps it off the foreground scheduler.
190+ */
191+export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => {
192+ const programArgs = options.programArguments
193+ .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
194+ .join("\n");
195+ const envEntries = Object.entries(options.environment)
196+ .map(
197+ ([key, value]) =>
198+ ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`,
199+ )
200+ .join("\n");
201+ return `<?xml version="1.0" encoding="UTF-8"?>
202+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
203+<plist version="1.0">
204+<dict>
205+ <key>Label</key>
206+ <string>${xmlEscape(options.label)}</string>
207+ <key>ProgramArguments</key>
208+ <array>
209+${programArgs}
210+ </array>
211+ <key>EnvironmentVariables</key>
212+ <dict>
213+${envEntries}
214+ </dict>
215+ <key>RunAtLoad</key>
216+ <true/>
217+ <key>KeepAlive</key>
218+ <dict>
219+ <key>SuccessfulExit</key>
220+ <false/>
221+ </dict>
222+ <key>ProcessType</key>
223+ <string>Background</string>
224+ <key>WorkingDirectory</key>
225+ <string>${xmlEscape(options.workingDirectory)}</string>
226+ <key>StandardOutPath</key>
227+ <string>${xmlEscape(options.stdoutPath)}</string>
228+ <key>StandardErrorPath</key>
229+ <string>${xmlEscape(options.stderrPath)}</string>
230+</dict>
231+</plist>
232+`;
233+};
234+
235+const parseLaunchctlPid = (printOutput: string): number | null => {
236+ const match = printOutput.match(/\bpid\s*=\s*(\d+)/);
237+ if (!match) return null;
238+ const pid = Number.parseInt(match[1], 10);
239+ return Number.isInteger(pid) && pid > 0 ? pid : null;
240+};
241+
242+const makeLaunchdBackend = (): ServiceBackend => {
243+ const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`;
244+
245+ return {
246+ platform: "darwin",
247+ automated: true,
248+ install: (descriptor) =>
249+ Effect.gen(function* () {
250+ const fs = yield* FileSystem.FileSystem;
251+ const path = yield* Path.Path;
252+ const uid = currentUid();
253+ const dataDir = resolveExecutorDataDir(path);
254+ const logs = serviceLogDir(path);
255+
256+ yield* fs.makeDirectory(launchAgentsDir(path), { recursive: true });
257+ yield* fs.makeDirectory(logs, { recursive: true });
258+
259+ const plist = generateLaunchdPlist({
260+ label: SERVICE_LABEL,
261+ programArguments: serviceProgramArguments(descriptor),
262+ environment: serviceEnvironment(descriptor, dataDir),
263+ stdoutPath: path.join(logs, "daemon.log"),
264+ stderrPath: path.join(logs, "daemon.error.log"),
265+ workingDirectory: dataDir,
266+ });
267+ const plistFile = launchdPlistPath(path);
268+ // 0600: the plist is owner-only. It carries no secret — the daemon reads
269+ // the bearer from auth.json at boot — but stays tight regardless.
270+ yield* fs.writeFileString(plistFile, plist, { mode: 0o600 });
271+
272+ // Re-bootstrap cleanly: a stale registration from a prior install would
273+ // make `bootstrap` fail with "service already loaded".
274+ yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
275+ const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]);
276+ if (bootstrap.code !== 0) {
277+ return yield* Effect.fail(
278+ new Error(
279+ `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`,
280+ ),
281+ );
282+ }
283+ yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore);
284+ }),
285+ uninstall: () =>
286+ Effect.gen(function* () {
287+ const fs = yield* FileSystem.FileSystem;
288+ const path = yield* Path.Path;
289+ const uid = currentUid();
290+ yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore);
291+ yield* runCommand("launchctl", ["disable", serviceTarget(uid)]).pipe(Effect.ignore);
292+ yield* fs.remove(launchdPlistPath(path), { force: true });
293+ }),
294+ status: () =>
295+ Effect.gen(function* () {
296+ const fs = yield* FileSystem.FileSystem;
297+ const path = yield* Path.Path;
298+ const uid = currentUid();
299+ const registered = yield* fs.exists(launchdPlistPath(path));
300+ const print = yield* runCommand("launchctl", ["print", serviceTarget(uid)]);
301+ const running = print.code === 0;
302+ return {
303+ platform: "darwin" as const,
304+ registered,
305+ running,
306+ pid: running ? parseLaunchctlPid(print.stdout) : null,
307+ detail: [],
308+ };
309+ }),
310+ restart: () =>
311+ Effect.gen(function* () {
312+ const uid = currentUid();
313+ const result = yield* runCommand("launchctl", ["kickstart", "-k", serviceTarget(uid)]);
314+ if (result.code !== 0) {
315+ return yield* Effect.fail(
316+ new Error(
317+ `launchctl kickstart failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`,
318+ ),
319+ );
320+ }
321+ }),
322+ };
323+};
324+
325+// ---------------------------------------------------------------------------
326+// Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM)
327+// ---------------------------------------------------------------------------
328+
329+const systemdUnitDir = (path: Path.Path): string =>
330+ path.join(homedir(), ".config", "systemd", "user");
331+
332+const systemdUnitPath = (path: Path.Path): string =>
333+ path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`);
334+
335+export interface SystemdUnitOptions {
336+ readonly execStart: ReadonlyArray<string>;
337+ readonly environment: Record<string, string>;
338+ readonly workingDirectory: string;
339+ readonly stdoutPath: string;
340+ readonly stderrPath: string;
341+}
342+
343+/** Render a systemd --user unit. Pure (snapshot-tested). */
344+export const generateSystemdUnit = (options: SystemdUnitOptions): string => {
345+ const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
346+ const env = Object.entries(options.environment)
347+ .map(([key, value]) => `Environment=${key}=${value}`)
348+ .join("\n");
349+ return `[Unit]
350+Description=Executor supervised daemon
351+After=default.target
352+
353+[Service]
354+Type=simple
355+ExecStart=${execStart}
356+${env}
357+WorkingDirectory=${options.workingDirectory}
358+StandardOutput=append:${options.stdoutPath}
359+StandardError=append:${options.stderrPath}
360+Restart=on-failure
361+RestartSec=5s
362+
363+[Install]
364+WantedBy=default.target
365+`;
366+};
367+
368+const makeSystemdBackend = (): ServiceBackend => {
369+ const unitName = `${SERVICE_LABEL}.service`;
370+ return {
371+ platform: "linux",
372+ automated: true,
373+ install: (descriptor) =>
374+ Effect.gen(function* () {
375+ const fs = yield* FileSystem.FileSystem;
376+ const path = yield* Path.Path;
377+ const dataDir = resolveExecutorDataDir(path);
378+ const logs = serviceLogDir(path);
379+ yield* fs.makeDirectory(systemdUnitDir(path), { recursive: true });
380+ yield* fs.makeDirectory(logs, { recursive: true });
381+ const unit = generateSystemdUnit({
382+ execStart: serviceProgramArguments(descriptor),
383+ environment: serviceEnvironment(descriptor, dataDir),
384+ workingDirectory: dataDir,
385+ stdoutPath: path.join(logs, "daemon.log"),
386+ stderrPath: path.join(logs, "daemon.error.log"),
387+ });
388+ yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 });
389+ // `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply
390+ // it if the caller's environment lacks it (e.g. a non-login shell) so
391+ // install is robust regardless of how it was invoked.
392+ const username = userInfo().username;
393+ const sdEnv = {
394+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
395+ };
396+ yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
397+ const enable = yield* runCommand(
398+ "systemctl",
399+ ["--user", "enable", "--now", unitName],
400+ sdEnv,
401+ );
402+ if (enable.code !== 0) {
403+ return yield* Effect.fail(
404+ new Error(
405+ `systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`,
406+ ),
407+ );
408+ }
409+ // Enable lingering so the user manager — and this enabled service —
410+ // starts at BOOT, not just on login, so the daemon survives a reboot
411+ // unattended (verified in a real Ubuntu VM via loginctl). Best-effort:
412+ // if the platform needs privilege, the service still works for the
413+ // logged-in case and `service status` flags the missing linger.
414+ yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore);
415+ }),
416+ uninstall: () =>
417+ Effect.gen(function* () {
418+ const fs = yield* FileSystem.FileSystem;
419+ const path = yield* Path.Path;
420+ const sdEnv = {
421+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
422+ };
423+ yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe(
424+ Effect.ignore,
425+ );
426+ yield* fs.remove(systemdUnitPath(path), { force: true });
427+ yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
428+ yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe(
429+ Effect.ignore,
430+ );
431+ }),
432+ status: () =>
433+ Effect.gen(function* () {
434+ const fs = yield* FileSystem.FileSystem;
435+ const path = yield* Path.Path;
436+ const sdEnv = {
437+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
438+ };
439+ const registered = yield* fs.exists(systemdUnitPath(path));
440+ const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv);
441+ const running = active.stdout.trim() === "active";
442+ const linger = yield* runCommand("loginctl", [
443+ "show-user",
444+ userInfo().username,
445+ "-p",
446+ "Linger",
447+ "--value",
448+ ]);
449+ const lingerOn = linger.stdout.trim() === "yes";
450+ return {
451+ platform: "linux" as const,
452+ registered,
453+ running,
454+ pid: null,
455+ detail: lingerOn
456+ ? []
457+ : [
458+ "Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.",
459+ ],
460+ };
461+ }),
462+ restart: () =>
463+ Effect.gen(function* () {
464+ const sdEnv = {
465+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`,
466+ };
467+ const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv);
468+ if (result.code !== 0) {
469+ return yield* Effect.fail(
470+ new Error(
471+ `systemctl --user restart failed (exit ${result.code}): ${result.stderr.trim()}`,
472+ ),
473+ );
474+ }
475+ }),
476+ };
477+};
478+
479+// ---------------------------------------------------------------------------
480+// Windows — Task Scheduler (S4U / AtStartup; reboot-survival verified)
481+// ---------------------------------------------------------------------------
482+
483+/** Scheduled Task name registered for the supervised daemon. */
484+export const WINDOWS_TASK_NAME = "ExecutorDaemon";
485+
486+/**
487+ * Make a value safe to embed in a cmd.exe `set "KEY=VALUE"` line. A literal `"`
488+ * would close the quoted argument early — in PATH (built from arbitrary
489+ * installer entries) that lets `& cmd &` fragments execute when Task Scheduler
490+ * runs the wrapper at boot, so strip them (a `"` is illegal in a Windows path
491+ * anyway). A literal `%` is re-expanded by cmd at run time against the boot
492+ * environment, silently diverging from the value captured at install; double it
493+ * so the daemon sees exactly what was captured.
494+ */
495+export const cmdSetValue = (value: string): string =>
496+ value.replaceAll('"', "").replaceAll("%", "%%");
497+
498+/**
499+ * The batch wrapper the Scheduled Task executes. Task Scheduler has no field
500+ * for environment variables, so the supervised env (EXECUTOR_SUPERVISED, data
501+ * dir, version, PATH) is baked into the wrapper as `set` lines before it execs
502+ * the daemon. stdout/stderr append to the same log files the other backends
503+ * use. CRLF line endings keep it a well-formed `.cmd`.
504+ */
505+export const generateWindowsDaemonWrapper = (
506+ descriptor: ServiceDescriptor,
507+ dataDir: string,
508+ logDir: string,
509+): string => {
510+ const env = serviceEnvironment(descriptor, dataDir);
511+ const setLines = Object.entries(env).map(([key, value]) => `set "${key}=${cmdSetValue(value)}"`);
512+ const [exe, ...rest] = serviceProgramArguments(descriptor);
513+ const command = `"${exe}" ${rest.join(" ")} 1>> "${logDir}\\daemon.log" 2>> "${logDir}\\daemon.error.log"`;
514+ return ["@echo off", ...setLines, command, ""].join("\r\n");
515+};
516+
517+/** Quote a value as a PowerShell single-quoted string literal. */
518+const psSingleQuote = (value: string): string => `'${value.replaceAll("'", "''")}'`;
519+
520+/**
521+ * PowerShell that registers the daemon as a boot-triggered Scheduled Task.
522+ *
523+ * LogonType=S4U + AtStartup is the Windows equivalent of launchd RunAtLoad and
524+ * systemd lingering: the task runs the daemon AS THE USER, at boot, with no
525+ * stored password and no interactive logon — verified to survive a real reboot
526+ * with no login on a headless host. RestartCount/RestartInterval supply the
527+ * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time
528+ * out a long-running task". Registering a boot task requires an elevated
529+ * (Administrator) shell.
530+ */
531+export const generateWindowsRegisterScript = (options: {
532+ readonly taskName: string;
533+ readonly wrapperPath: string;
534+ readonly userId: string;
535+}): string =>
536+ [
537+ `$action = New-ScheduledTaskAction -Execute ${psSingleQuote(options.wrapperPath)}`,
538+ `$trigger = New-ScheduledTaskTrigger -AtStartup`,
539+ `$principal = New-ScheduledTaskPrincipal -UserId ${psSingleQuote(options.userId)} -LogonType S4U -RunLevel Highest`,
540+ `$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)`,
541+ `Register-ScheduledTask -TaskName ${psSingleQuote(options.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`,
542+ `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`,
543+ ].join("\n");
544+
545+/** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */
546+const runPowerShell = (script: string): Effect.Effect<CommandResult, Error> =>
547+ runCommand("powershell.exe", [
548+ "-NoProfile",
549+ "-NonInteractive",
550+ "-ExecutionPolicy",
551+ "Bypass",
552+ "-EncodedCommand",
553+ Buffer.from(script, "utf16le").toString("base64"),
554+ ]);
555+
556+const makeWindowsBackend = (): ServiceBackend => ({
557+ platform: "win32",
558+ automated: true,
559+ install: (descriptor) =>
560+ Effect.gen(function* () {
561+ const fs = yield* FileSystem.FileSystem;
562+ const path = yield* Path.Path;
563+ const dataDir = resolveExecutorDataDir(path);
564+ const logs = serviceLogDir(path);
565+ const control = path.join(dataDir, "server-control");
566+ yield* fs.makeDirectory(logs, { recursive: true });
567+ yield* fs.makeDirectory(control, { recursive: true });
568+
569+ const wrapperPath = path.join(control, "run-daemon.cmd");
570+ yield* fs.writeFileString(
571+ wrapperPath,
572+ generateWindowsDaemonWrapper(descriptor, dataDir, logs),
573+ );
574+
575+ const result = yield* runPowerShell(
576+ generateWindowsRegisterScript({
577+ taskName: WINDOWS_TASK_NAME,
578+ wrapperPath,
579+ userId: userInfo().username,
580+ }),
581+ );
582+ if (result.code !== 0) {
583+ const detail = result.stderr.trim() || result.stdout.trim();
584+ const hint = /denied|0x80070005|administrator|elevat/i.test(detail)
585+ ? " Run `executor service install` from an Administrator PowerShell."
586+ : "";
587+ return yield* Effect.fail(
588+ new Error(`Register-ScheduledTask failed (exit ${result.code}): ${detail}.${hint}`),
589+ );
590+ }
591+ }),
592+ uninstall: () =>
593+ Effect.gen(function* () {
594+ const fs = yield* FileSystem.FileSystem;
595+ const path = yield* Path.Path;
596+ // Tolerate "task not found" (idempotent uninstall), but don't hide a
597+ // failure to even spawn PowerShell — that would leave the task registered
598+ // while we report success.
599+ yield* runPowerShell(
600+ `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` +
601+ `Unregister-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`,
602+ ).pipe(
603+ Effect.tapError((cause) =>
604+ Effect.sync(() =>
605+ console.warn(
606+ `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`,
607+ ),
608+ ),
609+ ),
610+ Effect.ignore,
611+ );
612+ const control = path.join(resolveExecutorDataDir(path), "server-control");
613+ yield* fs.remove(path.join(control, "run-daemon.cmd"), { force: true });
614+ }),
615+ status: () =>
616+ Effect.gen(function* () {
617+ const result = yield* runPowerShell(
618+ `$t = Get-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue; ` +
619+ `if ($null -eq $t) { 'NONE' } else { 'STATE=' + $t.State }`,
620+ );
621+ const out = result.stdout.trim();
622+ if (result.code !== 0 || out === "" || out.includes("NONE")) {
623+ return {
624+ platform: "win32" as const,
625+ registered: false,
626+ running: false,
627+ pid: null,
628+ detail: ["No ExecutorDaemon scheduled task registered. Run `executor service install`."],
629+ };
630+ }
631+ const state = /STATE=(\w+)/.exec(out)?.[1] ?? "Unknown";
632+ const running = state === "Running";
633+ return {
634+ platform: "win32" as const,
635+ registered: true,
636+ running,
637+ pid: null,
638+ detail: running ? [] : [`Scheduled task registered; current state: ${state}.`],
639+ };
640+ }),
641+ restart: () =>
642+ Effect.gen(function* () {
643+ const result = yield* runPowerShell(
644+ `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` +
645+ `Start-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)}`,
646+ );
647+ if (result.code !== 0) {
648+ return yield* Effect.fail(
649+ new Error(
650+ `Failed to restart ExecutorDaemon task (exit ${result.code}): ${result.stderr.trim()}`,
651+ ),
652+ );
653+ }
654+ }),
655+});
656+
657+const makeUnsupportedBackend = (): ServiceBackend => ({
658+ platform: "unsupported",
659+ automated: false,
660+ install: () =>
661+ Effect.fail(new Error(`OS service install is not supported on ${process.platform}.`)),
662+ uninstall: () =>
663+ Effect.fail(new Error(`OS service uninstall is not supported on ${process.platform}.`)),
664+ status: () =>
665+ Effect.succeed({
666+ platform: "unsupported" as const,
667+ registered: false,
668+ running: false,
669+ pid: null,
670+ detail: [`OS service management is not supported on ${process.platform}.`],
671+ }),
672+ restart: () =>
673+ Effect.fail(new Error(`OS service restart is not supported on ${process.platform}.`)),
674+});
675+
676+/** Select the service backend for the current OS. */
677+export const getServiceBackend = (platform: NodeJS.Platform = process.platform): ServiceBackend => {
678+ switch (platform) {
679+ case "darwin":
680+ return makeLaunchdBackend();
681+ case "linux":
682+ return makeSystemdBackend();
683+ case "win32":
684+ return makeWindowsBackend();
685+ default:
686+ return makeUnsupportedBackend();
687+ }
688+};
apps/cli/src/tooling.test.ts+76−1
@@ -1,6 +1,81 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { BunServices } from "@effect/platform-bun";
3+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import * as Effect from "effect/Effect";
27
3−import { sanitizeCliOutputText, shellQuoteArg } from "./tooling";
8+import { resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg } from "./tooling";
9+
10+const withTmp = <A, E, R>(body: (dir: string) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
11+ Effect.acquireUseRelease(
12+ Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-call-"))),
13+ body,
14+ (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })),
15+ );
16+
17+describe("resolveToolInvocation", () => {
18+ it.effect("reads the JSON arg from a file via @path", () =>
19+ withTmp((dir) =>
20+ Effect.gen(function* () {
21+ const file = join(dir, "input.json");
22+ writeFileSync(file, '{"title":"Hi","n":2}');
23+ const result = yield* resolveToolInvocation({
24+ rawPathParts: ["github", "issues", "create", `@${file}`],
25+ });
26+ expect(result.path).toBe("github.issues.create");
27+ expect(result.args).toEqual({ title: "Hi", n: 2 });
28+ }),
29+ ).pipe(Effect.provide(BunServices.layer)),
30+ );
31+
32+ it.effect("still accepts inline JSON (slice condition regression)", () =>
33+ Effect.gen(function* () {
34+ const result = yield* resolveToolInvocation({
35+ rawPathParts: ["github", "issues", "create", '{"title":"Hi"}'],
36+ });
37+ expect(result.path).toBe("github.issues.create");
38+ expect(result.args).toEqual({ title: "Hi" });
39+ }).pipe(Effect.provide(BunServices.layer)),
40+ );
41+
42+ it.effect("treats a path with no trailing JSON as empty args", () =>
43+ Effect.gen(function* () {
44+ const result = yield* resolveToolInvocation({ rawPathParts: ["github", "issues", "list"] });
45+ expect(result.path).toBe("github.issues.list");
46+ expect(result.args).toEqual({});
47+ }).pipe(Effect.provide(BunServices.layer)),
48+ );
49+
50+ it.effect("fails with a path-bearing message when the @file is missing", () =>
51+ Effect.gen(function* () {
52+ const error = yield* Effect.flip(
53+ resolveToolInvocation({ rawPathParts: ["x", "@/no/such/file.json"] }),
54+ );
55+ expect(error.message).toContain("/no/such/file.json");
56+ }).pipe(Effect.provide(BunServices.layer)),
57+ );
58+
59+ it.effect("rejects an @file whose content is not a JSON object", () =>
60+ withTmp((dir) =>
61+ Effect.gen(function* () {
62+ const file = join(dir, "bad.json");
63+ writeFileSync(file, "[1,2,3]");
64+ const error = yield* Effect.flip(
65+ resolveToolInvocation({ rawPathParts: ["x", `@${file}`] }),
66+ );
67+ expect(error.message).toContain("must contain a JSON object");
68+ }),
69+ ).pipe(Effect.provide(BunServices.layer)),
70+ );
71+
72+ it.effect("rejects a bare '@' with no path", () =>
73+ Effect.gen(function* () {
74+ const error = yield* Effect.flip(resolveToolInvocation({ rawPathParts: ["x", "@"] }));
75+ expect(error.message).toContain("requires a file path");
76+ }).pipe(Effect.provide(BunServices.layer)),
77+ );
78+});
479
580 describe("shellQuoteArg", () => {
681 it("quotes single quotes without breaking the shell argument", () => {
apps/cli/src/tooling.ts+60−0
@@ -1,3 +1,4 @@
1+import { FileSystem } from "effect";
12 import * as Effect from "effect/Effect";
23
34 const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -145,6 +146,65 @@ export const parseJsonObjectInput = ( · 141 unmodified lines
145146 return parsed;
146147 });
147148
149+export const resolveToolInvocation = (input: {
150+ rawPathParts: ReadonlyArray<string>;
151+}): Effect.Effect<{ path: string; args: Record<string, unknown> }, Error, FileSystem.FileSystem> =>
152+ Effect.gen(function* () {
153+ if (!Array.isArray(input.rawPathParts)) {
154+ return yield* Effect.fail(
155+ new Error("Invalid tool invocation: path parts were not parsed as an array"),
156+ );
157+ }
158+
159+ // The trailing argument carries the tool's JSON input — either inline
160+ // (`'{"k":"v"}'`) or, via `@path`, read from a file. The file form is the
161+ // cross-platform equivalent of the Unix `"$(cat file)"`: it dodges shell
162+ // quote-mangling for large or double-quote-heavy payloads (notably
163+ // PowerShell, which corrupts inline JSON passed to a native binary).
164+ const rawLast = input.rawPathParts.at(-1)?.trim();
165+ const isFileArg = rawLast !== undefined && rawLast.startsWith("@");
166+ const filePath = isFileArg ? rawLast.slice(1) : undefined;
167+ if (isFileArg && filePath === "") {
168+ return yield* Effect.fail(
169+ new Error("Tool input '@' requires a file path, e.g. `@./input.json`."),
170+ );
171+ }
172+ const jsonText =
173+ filePath !== undefined
174+ ? (yield* (yield* FileSystem.FileSystem).readFileString(filePath).pipe(
175+ // Surface a path-bearing message instead of a raw ENOENT PlatformError.
176+ Effect.mapError(
177+ (cause) => new Error(`Cannot read tool input file '${filePath}': ${cause}`),
178+ ),
179+ )).trim()
180+ : rawLast;
181+ const hasInlineJsonArg = jsonText !== undefined && jsonText.startsWith("{");
182+ if (isFileArg && !hasInlineJsonArg) {
183+ return yield* Effect.fail(
184+ new Error(`Tool input file '${filePath}' must contain a JSON object starting with '{'.`),
185+ );
186+ }
187+ const pathParts =
188+ isFileArg || hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts;
189+ const args = hasInlineJsonArg ? yield* parseJsonObjectInput(jsonText) : {};
190+
191+ if (pathParts.some((part) => part.trim().startsWith("-"))) {
192+ return yield* Effect.fail(
193+ new Error(
194+ "Tool invocation no longer accepts flags. Use: executor call <path...> '{...json...}'",
195+ ),
196+ );
197+ }
198+
199+ const path = yield* Effect.try({
200+ try: () => buildToolPath(pathParts),
201+ catch: (cause) =>
202+ cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`),
203+ });
204+
205+ return { path, args };
206+ });
207+
148208 export const extractExecutionResult = (structured: unknown): unknown => {
149209 if (!isRecord(structured) || !("result" in structured)) {
150210 return null;