Skip to content

Commit 0f9306a

Browse files
committed
Add executor service to run the local gateway as an OS-supervised daemon
Registers the existing daemon (`daemon run --foreground`) with the OS service manager so the local MCP gateway survives app-quit and machine restart: - launchd LaunchAgent on macOS (RunAtLoad + KeepAlive=SuccessfulExit:false), with best-effort systemd --user (Linux) and a guided Task Scheduler scaffold (Windows). - install / uninstall / status / restart subcommands; install refuses to fight an already-running local server and polls until the daemon is reachable. - The Basic-auth password lives in a 0600 service.key the supervised daemon reads on start -- never in the unit, so launchctl/systemctl can't expose it. - The unit bakes the user's PATH (a bare launchd env can't find pyenv/nvm/brew tools) and the install version (`service status` flags drift after upgrade). - Unit tests cover the plist/systemd/Windows generators, backend dispatch, and the 0600 service.key round-trip.
1 parent 34b2c67 commit 0f9306a

5 files changed

Lines changed: 933 additions & 4 deletions

File tree

apps/cli/src/local-server-manifest.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, it } from "@effect/vitest";
22
import { BunServices } from "@effect/platform-bun";
3-
import { mkdtempSync, rmSync } from "node:fs";
3+
import { mkdtempSync, rmSync, statSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { Path } from "effect";
@@ -11,10 +11,13 @@ import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
1111
import {
1212
acquireLocalServerStartLock,
1313
readLocalServerManifest,
14+
readServiceKey,
1415
releaseLocalServerStartLock,
1516
removeLocalServerManifestIfOwnedBy,
17+
removeServiceKey,
1618
resolveExecutorDataDir,
1719
writeLocalServerManifest,
20+
writeServiceKey,
1821
} from "./local-server-manifest";
1922

2023
const previousDataDir = process.env.EXECUTOR_DATA_DIR;
@@ -84,6 +87,30 @@ describe("local server manifest", () => {
8487
}).pipe(Effect.provide(BunServices.layer)),
8588
);
8689

90+
it.effect("round-trips the service key with owner-only permissions", () =>
91+
Effect.gen(function* () {
92+
const dataDir = mkdtempSync(join(tmpdir(), "executor-service-key-"));
93+
process.env.EXECUTOR_DATA_DIR = dataDir;
94+
95+
try {
96+
expect(yield* readServiceKey()).toBeNull();
97+
98+
yield* writeServiceKey({ password: "s3cret", createdAt: "2026-06-13T00:00:00.000Z" });
99+
const key = yield* readServiceKey();
100+
expect(key?.password).toBe("s3cret");
101+
102+
// The secret file must be readable only by its owner.
103+
const mode = statSync(join(dataDir, "server-control", "service.key")).mode & 0o777;
104+
expect(mode).toBe(0o600);
105+
106+
yield* removeServiceKey();
107+
expect(yield* readServiceKey()).toBeNull();
108+
} finally {
109+
rmSync(dataDir, { recursive: true, force: true });
110+
}
111+
}).pipe(Effect.provide(BunServices.layer)),
112+
);
113+
87114
it.effect("resolves the data dir from EXECUTOR_DATA_DIR", () =>
88115
Effect.gen(function* () {
89116
const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-dir-"));

apps/cli/src/local-server-manifest.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const localServerManifestPath = (path: Path.Path): string =>
2727
const localServerStartLockPath = (path: Path.Path): string =>
2828
path.join(serverControlDir(path), "startup.lock");
2929

30+
const serviceKeyPath = (path: Path.Path): string =>
31+
path.join(serverControlDir(path), "service.key");
32+
3033
export const readLocalServerManifest = (): Effect.Effect<
3134
ExecutorLocalServerManifest | null,
3235
never,
@@ -128,3 +131,72 @@ export const releaseLocalServerStartLock = (
128131
const fs = yield* FileSystem.FileSystem;
129132
yield* fs.remove(lock.path, { force: true });
130133
});
134+
135+
// ---------------------------------------------------------------------------
136+
// Service key — the stable Basic-auth password for the OS-supervised daemon.
137+
//
138+
// A launchd/systemd-managed daemon needs a password that survives reinstalls
139+
// and is discoverable across processes. We persist it once at
140+
// `~/.executor/server-control/service.key` (mode 0600) so `service install`
141+
// reuses the same value on reinstall rather than minting a new one (which would
142+
// break every MCP-client config pointing at the old credential). The password
143+
// itself reaches the running daemon via the service manifest / plist env, and
144+
// clients discover it from `server.json`; this file is the durable source of
145+
// truth that backs both.
146+
// ---------------------------------------------------------------------------
147+
148+
export interface ServiceKey {
149+
readonly password: string;
150+
readonly createdAt: string;
151+
}
152+
153+
const ServiceKeyPayload = Schema.Struct({
154+
password: Schema.String,
155+
createdAt: Schema.String,
156+
});
157+
158+
const decodeServiceKeyPayload = Schema.decodeUnknownOption(
159+
Schema.fromJsonString(ServiceKeyPayload),
160+
);
161+
162+
export const readServiceKey = (): Effect.Effect<
163+
ServiceKey | null,
164+
never,
165+
FileSystem.FileSystem | Path.Path
166+
> =>
167+
Effect.gen(function* () {
168+
const fs = yield* FileSystem.FileSystem;
169+
const path = yield* Path.Path;
170+
const raw = yield* fs
171+
.readFileString(serviceKeyPath(path))
172+
.pipe(Effect.catchCause(() => Effect.succeed(null)));
173+
if (raw === null) return null;
174+
const decoded = decodeServiceKeyPayload(raw);
175+
return Option.isSome(decoded) ? decoded.value : null;
176+
});
177+
178+
export const writeServiceKey = (
179+
key: ServiceKey,
180+
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
181+
Effect.gen(function* () {
182+
const fs = yield* FileSystem.FileSystem;
183+
const path = yield* Path.Path;
184+
yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
185+
yield* fs.writeFileString(
186+
serviceKeyPath(path),
187+
`${JSON.stringify(key, null, 2)}\n`,
188+
// Owner-only: the password is readable only by the user who installed it.
189+
{ mode: 0o600 },
190+
);
191+
});
192+
193+
export const removeServiceKey = (): Effect.Effect<
194+
void,
195+
PlatformError,
196+
FileSystem.FileSystem | Path.Path
197+
> =>
198+
Effect.gen(function* () {
199+
const fs = yield* FileSystem.FileSystem;
200+
const path = yield* Path.Path;
201+
yield* fs.remove(serviceKeyPath(path), { force: true });
202+
});

apps/cli/src/main.ts

Lines changed: 199 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// before any import (e.g. `@executor-js/local` → libSQL) eagerly loads them.
33
import "./native-bindings";
44

5-
import { randomUUID } from "node:crypto";
5+
import { randomBytes, randomUUID } from "node:crypto";
66
import { existsSync, realpathSync } from "node:fs";
77
import { dirname, join, resolve } from "node:path";
88
// Make sibling binaries (if any are added later) discoverable on $PATH so
@@ -93,11 +93,15 @@ import {
9393
import {
9494
acquireLocalServerStartLock,
9595
readLocalServerManifest,
96+
readServiceKey,
9697
releaseLocalServerStartLock,
9798
removeLocalServerManifestIfOwnedBy,
99+
removeServiceKey,
98100
resolveExecutorDataDir,
99101
writeLocalServerManifest,
102+
writeServiceKey,
100103
} from "./local-server-manifest";
104+
import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service";
101105
import {
102106
defaultCliServerConnectionProfile,
103107
findCliServerConnectionProfile,
@@ -1993,12 +1997,24 @@ const daemonRunCommand = Command.make(
19931997
Effect.gen(function* () {
19941998
applyScope(scope);
19951999
if (foreground) {
2000+
// Auth resolution for the foreground daemon (the form OS service
2001+
// managers run). Flag wins; then EXECUTOR_AUTH_PASSWORD/TOKEN env (how
2002+
// a dynamically-written launchd/systemd unit injects the secret); then,
2003+
// only when running supervised, the durable service.key (how the
2004+
// desktop's static SMAppService plist — which can't carry a per-user
2005+
// secret — supplies it).
2006+
const flagPassword = Option.getOrUndefined(authPassword);
2007+
const envPassword = process.env.EXECUTOR_AUTH_PASSWORD;
2008+
const keyPassword =
2009+
process.env.EXECUTOR_SUPERVISED && !flagPassword && !envPassword
2010+
? ((yield* readServiceKey())?.password ?? undefined)
2011+
: undefined;
19962012
yield* runDaemonSession({
19972013
port,
19982014
hostname,
19992015
allowedHosts: allowedHost,
2000-
authToken: Option.getOrUndefined(authToken),
2001-
authPassword: Option.getOrUndefined(authPassword),
2016+
authToken: Option.getOrUndefined(authToken) ?? process.env.EXECUTOR_AUTH_TOKEN,
2017+
authPassword: flagPassword ?? envPassword ?? keyPassword,
20022018
});
20032019
} else {
20042020
yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost });
@@ -2110,6 +2126,185 @@ const mcpCommand = Command.make(
21102126
}),
21112127
).pipe(Command.withDescription("Start an MCP server over stdio"));
21122128

2129+
// ---------------------------------------------------------------------------
2130+
// Service — register the daemon with the OS so it survives app-quit + restart
2131+
// ---------------------------------------------------------------------------
2132+
2133+
const generateServicePassword = (): string => randomBytes(24).toString("base64url");
2134+
2135+
const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2136+
2137+
const supervisedServiceAuthorization = (
2138+
password: string | null,
2139+
port: number,
2140+
): string | undefined =>
2141+
getExecutorServerAuthorizationHeader(
2142+
normalizeExecutorServerConnection({
2143+
origin: supervisedServiceOrigin(port),
2144+
...(password
2145+
? {
2146+
auth: {
2147+
kind: "basic" as const,
2148+
username: DEFAULT_EXECUTOR_SERVER_USERNAME,
2149+
password,
2150+
},
2151+
}
2152+
: {}),
2153+
}),
2154+
) ?? undefined;
2155+
2156+
const serviceInstallCommand = Command.make(
2157+
"install",
2158+
{
2159+
port: Options.integer("port")
2160+
.pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2161+
.pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
2162+
password: Options.string("password")
2163+
.pipe(Options.optional)
2164+
.pipe(
2165+
Options.withDescription(
2166+
"Basic auth password. Reused from a prior install or generated if omitted.",
2167+
),
2168+
),
2169+
noAuth: Options.boolean("no-auth")
2170+
.pipe(Options.withDefault(false))
2171+
.pipe(Options.withDescription("Run without Basic auth (loopback trust only).")),
2172+
},
2173+
({ port, password, noAuth }) =>
2174+
Effect.gen(function* () {
2175+
if (isDevMode) {
2176+
return yield* Effect.fail(
2177+
new Error(
2178+
[
2179+
"`service install` requires the compiled `executor` binary so the OS can run it directly.",
2180+
`In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2181+
].join("\n"),
2182+
),
2183+
);
2184+
}
2185+
2186+
const backend = getServiceBackend();
2187+
if (!backend.automated) {
2188+
// win32 / unsupported: the backend surfaces manual steps via its error.
2189+
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2190+
return;
2191+
}
2192+
2193+
// Don't fight an already-running local server against the same data dir
2194+
// (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2195+
const active = yield* readActiveLocalServerManifest();
2196+
if (active) {
2197+
const status = yield* backend.status();
2198+
if (status.registered && status.running && active.kind === "cli-daemon") {
2199+
console.log(
2200+
`Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
2201+
);
2202+
return;
2203+
}
2204+
return yield* Effect.fail(
2205+
new Error(
2206+
[
2207+
`A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2208+
`Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2209+
].join("\n"),
2210+
),
2211+
);
2212+
}
2213+
2214+
let servicePassword: string | null = null;
2215+
if (!noAuth) {
2216+
const explicit = Option.getOrUndefined(password);
2217+
const existing = yield* readServiceKey();
2218+
servicePassword = explicit ?? existing?.password ?? generateServicePassword();
2219+
yield* writeServiceKey({ password: servicePassword, createdAt: new Date().toISOString() });
2220+
}
2221+
2222+
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2223+
2224+
const origin = supervisedServiceOrigin(port);
2225+
const reachable = yield* waitForReachable({
2226+
check: isServerReachable(origin, supervisedServiceAuthorization(servicePassword, port)),
2227+
timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2228+
intervalMs: DAEMON_BOOT_POLL_MS,
2229+
});
2230+
if (!reachable) {
2231+
return yield* Effect.fail(
2232+
new Error(
2233+
[
2234+
`Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2235+
`Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2236+
].join("\n"),
2237+
),
2238+
);
2239+
}
2240+
2241+
console.log(`Executor is now running as a background service at ${origin}.`);
2242+
console.log("It keeps serving after you quit the app and restarts on login.");
2243+
if (servicePassword) {
2244+
console.log(
2245+
`Basic auth user "${DEFAULT_EXECUTOR_SERVER_USERNAME}"; password saved to ~/.executor/server-control/service.key.`,
2246+
);
2247+
}
2248+
}),
2249+
).pipe(
2250+
Command.withDescription("Install and start Executor as an OS-supervised background service"),
2251+
);
2252+
2253+
const serviceUninstallCommand = Command.make("uninstall", {}, () =>
2254+
Effect.gen(function* () {
2255+
const backend = getServiceBackend();
2256+
yield* backend.uninstall();
2257+
yield* removeServiceKey().pipe(Effect.ignore);
2258+
console.log("Executor background service uninstalled.");
2259+
}),
2260+
).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));
2261+
2262+
const serviceStatusCommand = Command.make("status", {}, () =>
2263+
Effect.gen(function* () {
2264+
const backend = getServiceBackend();
2265+
const status = yield* backend.status();
2266+
// Tolerate a registered-but-unreachable manifest here — status shouldn't throw.
2267+
const active = yield* readActiveLocalServerManifest().pipe(
2268+
Effect.catchCause(() => Effect.succeed(null)),
2269+
);
2270+
console.log(`Platform: ${status.platform}`);
2271+
console.log(`Registered: ${status.registered ? "yes" : "no"}`);
2272+
console.log(
2273+
`Running: ${status.running ? "yes" : "no"}${status.pid ? ` (pid ${status.pid})` : ""}`,
2274+
);
2275+
if (active) {
2276+
console.log(`Serving: ${active.connection.origin} (${active.kind}, pid ${active.pid})`);
2277+
// Version drift: the running daemon was launched by the binary the unit
2278+
// points at. If that differs from this CLI, an upgrade left the unit
2279+
// pointing at an older binary — reinstall to repoint + restart.
2280+
if (active.owner.version && active.owner.version !== CLI_VERSION) {
2281+
console.log(
2282+
`Drift: running ${active.owner.version}, current ${CLI_VERSION} — run \`${cliPrefix} service install\` to upgrade.`,
2283+
);
2284+
}
2285+
}
2286+
for (const line of status.detail) console.log(line);
2287+
}),
2288+
).pipe(Command.withDescription("Show the OS-supervised service status"));
2289+
2290+
const serviceRestartCommand = Command.make("restart", {}, () =>
2291+
Effect.gen(function* () {
2292+
const backend = getServiceBackend();
2293+
yield* backend.restart();
2294+
console.log("Executor background service restarted.");
2295+
}),
2296+
).pipe(Command.withDescription("Restart the OS-supervised background service"));
2297+
2298+
const serviceCommand = Command.make("service").pipe(
2299+
Command.withSubcommands([
2300+
serviceInstallCommand,
2301+
serviceUninstallCommand,
2302+
serviceStatusCommand,
2303+
serviceRestartCommand,
2304+
] as const),
2305+
Command.withDescription("Manage the OS-supervised background service"),
2306+
);
2307+
21132308
// ---------------------------------------------------------------------------
21142309
// Root command
21152310
// ---------------------------------------------------------------------------
@@ -2122,6 +2317,7 @@ const root = Command.make("executor").pipe(
21222317
serverCommand,
21232318
webCommand,
21242319
daemonCommand,
2320+
serviceCommand,
21252321
mcpCommand,
21262322
] as const),
21272323
Command.withDescription("Executor local CLI"),

0 commit comments

Comments
 (0)