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
3import type { AddressInfo } from "node:net";3import type { AddressInfo } from "node:net";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5 5
6import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon";6import {
7 canAutoStartLocalDaemonForHost,
8 isDevCliEntrypoint,
9 isExecutorServerReachable,
10} from "./daemon";
11
12describe("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});
7 38
8describe("canAutoStartLocalDaemonForHost", () => {39describe("canAutoStartLocalDaemonForHost", () => {
9 it("allows loopback hosts", () => {40 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
53// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
54 54
55const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);55const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
56const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/";
57 56
58export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>57export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
59 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());58 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
60 59
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 */
66const 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 */
61export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {77export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {
62 if (!scriptPath) return false;78 if (!scriptPath) return false;
63 if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false;79 const normalized = scriptPath.replaceAll("\\", "/");
64 return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js");80 if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false;
81 return normalized.endsWith(".ts") || normalized.endsWith(".js");
65};82};
66 83
67export const isExecutorServerReachable = (84export const isExecutorServerReachable = (
apps/cli/src/local-server-manifest.ts+19−0
@@ -76,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: { · 75 unmodified lines
76 yield* fs.remove(manifestPath, { force: true });76 yield* fs.remove(manifestPath, { force: true });
77 });77 });
78 78
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 */
87export 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
79const StartupLockPayload = Schema.Struct({98const StartupLockPayload = Schema.Struct({
80 pid: Schema.Number,99 pid: Schema.Number,
81});100});
apps/cli/src/main.ts+159−34
@@ -100,10 +100,12 @@ import { · 99 unmodified lines
100 acquireLocalServerStartLock,100 acquireLocalServerStartLock,
101 readLocalServerManifest,101 readLocalServerManifest,
102 releaseLocalServerStartLock,102 releaseLocalServerStartLock,
103 removeLocalServerManifest,
103 removeLocalServerManifestIfOwnedBy,104 removeLocalServerManifestIfOwnedBy,
104 resolveExecutorDataDir,105 resolveExecutorDataDir,
105 writeLocalServerManifest,106 writeLocalServerManifest,
106} from "./local-server-manifest";107} from "./local-server-manifest";
108import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service";
107import {109import {
108 defaultCliServerConnectionProfile,110 defaultCliServerConnectionProfile,
109 findCliServerConnectionProfile,111 findCliServerConnectionProfile,
@@ -115,7 +117,6 @@ import { · 5 unmodified lines
115} from "./server-profile";117} from "./server-profile";
116import {118import {
117 buildResumeContentTemplate,119 buildResumeContentTemplate,
118 buildToolPath,
119 buildDescribeToolCode,120 buildDescribeToolCode,
120 filterToolPathChildren,121 filterToolPathChildren,
121 buildInvokeToolCode,122 buildInvokeToolCode,
@@ -127,6 +128,7 @@ import { · 5 unmodified lines
127 inspectToolPath,128 inspectToolPath,
128 normalizeCliErrorText,129 normalizeCliErrorText,
129 parseJsonObjectInput,130 parseJsonObjectInput,
131 resolveToolInvocation,
130 sanitizeCliOutputText,132 sanitizeCliOutputText,
131 shellQuoteArg,133 shellQuoteArg,
132} from "./tooling";134} from "./tooling";
@@ -886,7 +888,19 @@ const runDaemonSession = (input: { · 753 unmodified lines
886 let token: string | null = null;888 let token: string | null = null;
887 889
888 try {890 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 }
890 904
891 const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });905 const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
892 906
@@ -1572,38 +1586,6 @@ const runCallHelp = ( · 679 unmodified lines
1572 });1586 });
1573 }).pipe(Effect.mapError(toError));1587 }).pipe(Effect.mapError(toError));
1574 1588
1575const 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
1607// ---------------------------------------------------------------------------1589// ---------------------------------------------------------------------------
1608// Commands1590// Commands
1609// ---------------------------------------------------------------------------1591// ---------------------------------------------------------------------------
@@ -2000,6 +1982,10 @@ const daemonRunCommand = Command.make( · 390 unmodified lines
2000 Effect.gen(function* () {1982 Effect.gen(function* () {
2001 applyScope(scope);1983 applyScope(scope);
2002 if (foreground) {1984 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.
2003 yield* runDaemonSession({1989 yield* runDaemonSession({
2004 port,1990 port,
2005 hostname,1991 hostname,
@@ -2116,6 +2102,144 @@ const mcpCommand = Command.make( · 110 unmodified lines
2116 }),2102 }),
2117).pipe(Command.withDescription("Start an MCP server over stdio"));2103).pipe(Command.withDescription("Start an MCP server over stdio"));
2118 2104
2105// ---------------------------------------------------------------------------
2106// Service — register the daemon with the OS so it survives app-quit + restart
2107// ---------------------------------------------------------------------------
2108
2109const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2110
2111const 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
2189const 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
2197const 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
2225const 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
2233const 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
2119// ---------------------------------------------------------------------------2243// ---------------------------------------------------------------------------
2120// Root command2244// Root command
2121// ---------------------------------------------------------------------------2245// ---------------------------------------------------------------------------
@@ -2170,6 +2294,7 @@ const root = Command.make("executor").pipe( · 48 unmodified lines
2170 serverCommand,2294 serverCommand,
2171 webCommand,2295 webCommand,
2172 daemonCommand,2296 daemonCommand,
2297 serviceCommand,
2173 mcpCommand,2298 mcpCommand,
2174 openCommand,2299 openCommand,
2175 ] as const),2300 ] as const),
apps/cli/src/service.test.ts+148−0
@@ -0,0 +1,148 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 cmdSetValue,
5 generateLaunchdPlist,
6 generateSystemdUnit,
7 generateWindowsDaemonWrapper,
8 generateWindowsRegisterScript,
9 getServiceBackend,
10} from "./service";
11
12describe("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
128describe("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 @@
1import { execFile } from "node:child_process";
2import { homedir, userInfo } from "node:os";
3import { FileSystem, Path } from "effect";
4import type { PlatformError } from "effect/PlatformError";
5import * as Effect from "effect/Effect";
6
7import { 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
23export 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 */
31export const DEFAULT_SERVICE_PORT = 4789;
32
33export 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
46export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported";
47
48export 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
59export 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
85interface CommandResult {
86 readonly stdout: string;
87 readonly stderr: string;
88 readonly code: number;
89}
90
91const 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
116const 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
122const 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
134const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray<string> => [
135 descriptor.executablePath,
136 "daemon",
137 "run",
138 "--foreground",
139 "--port",
140 String(descriptor.port),
141];
142
143const 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
167const launchAgentsDir = (path: Path.Path): string =>
168 path.join(homedir(), "Library", "LaunchAgents");
169
170const launchdPlistPath = (path: Path.Path): string =>
171 path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`);
172
173const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
174
175export 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 */
191export 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
235const 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
242const 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
329const systemdUnitDir = (path: Path.Path): string =>
330 path.join(homedir(), ".config", "systemd", "user");
331
332const systemdUnitPath = (path: Path.Path): string =>
333 path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`);
334
335export 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). */
344export 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]
350Description=Executor supervised daemon
351After=default.target
352
353[Service]
354Type=simple
355ExecStart=${execStart}
356${env}
357WorkingDirectory=${options.workingDirectory}
358StandardOutput=append:${options.stdoutPath}
359StandardError=append:${options.stderrPath}
360Restart=on-failure
361RestartSec=5s
362
363[Install]
364WantedBy=default.target
365`;
366};
367
368const 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. */
484export 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 */
495export 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 */
505export 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. */
518const 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 */
531export 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). */
546const 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
556const 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
657const 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. */
677export 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import * as Effect from "effect/Effect";
2 7
3import { sanitizeCliOutputText, shellQuoteArg } from "./tooling";8import { resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg } from "./tooling";
9
10const 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
17describe("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});
4 79
5describe("shellQuoteArg", () => {80describe("shellQuoteArg", () => {
6 it("quotes single quotes without breaking the shell argument", () => {81 it("quotes single quotes without breaking the shell argument", () => {
apps/cli/src/tooling.ts+60−0
@@ -1,3 +1,4 @@
1import { FileSystem } from "effect";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2 3
3const isRecord = (value: unknown): value is Record<string, unknown> =>4const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -145,6 +146,65 @@ export const parseJsonObjectInput = ( · 141 unmodified lines
145 return parsed;146 return parsed;
146 });147 });
147 148
149export 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
148export const extractExecutionResult = (structured: unknown): unknown => {208export const extractExecutionResult = (structured: unknown): unknown => {
149 if (!isRecord(structured) || !("result" in structured)) {209 if (!isRecord(structured) || !("result" in structured)) {
150 return null;210 return null;