Skip to content

Add executor service to supervise the local gateway daemon#1004

Merged
RhysSullivan merged 1 commit into
mainfrom
daemon/cli-service
Jun 14, 2026
Merged

Add executor service to supervise the local gateway daemon#1004
RhysSullivan merged 1 commit into
mainfrom
daemon/cli-service

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Problem

The local MCP gateway is owned by a foreground process. Quit the desktop app (or restart the machine) and the /mcp endpoint disappears, so connected agents silently lose their tools. The daemon to keep it alive already exists (executor daemon run --foreground) — it just isn't registered with the OS.

What this adds

executor service install | uninstall | status | restart — registers that daemon with the OS service manager so it survives app-quit and machine restart.

  • macOS (built + verified): a launchd LaunchAgent with RunAtLoad + KeepAlive={SuccessfulExit:false} (restart on crash, stay stopped on a clean bootout).
  • Linux: a best-effort systemd --user unit (Restart=on-failure, WantedBy=default.target).
  • Windows: a guided Task Scheduler scaffold that prints the exact command rather than claiming registration it can't yet verify.

Design notes:

  • The secret is never in the unit. The Basic-auth password lives in a 0600 service.key that the supervised daemon reads on start, so launchctl print/list and systemctl cat never expose it.
  • PATH is baked in. A launchd/systemd unit starts with a bare PATH; without the user's PATH the daemon can't find pyenv/nvm/Homebrew tools that integrations shell out to. service install runs from the user's shell, so its PATH is the right one to capture.
  • Version drift is surfaced by service status (running daemon's version vs the installed CLI) so an upgrade that left the unit pointing at an old binary is visible.
  • install refuses to fight an already-running local server and polls until the daemon is reachable.

Verification

  • Unit tests: plist/systemd/Windows generators, backend dispatch, and the 0600 service.key round-trip.
  • Live on macOS (isolated data dir + test port): installGET / returns 200 → restart (kickstart) → still 200 → uninstall → endpoint refused, plist removed. Confirmed EXECUTOR_AUTH_PASSWORD is absent from the plist and service.key is mode 600.

A launchd-based persistence e2e isn't included: it can't bootstrap a real LaunchAgent in standard CI. The unit tests plus the live run above are the coverage.

Stack

  1. Add busy_timeout to the local libSQL connection #1003
  2. Add executor service to supervise the local gateway daemon #1004 👈 current
  3. Desktop: attach to the supervised daemon instead of a private sidecar #1005

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 13, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing 2126859 Commit Preview URL

Branch Preview URL
Jun 14 2026, 07:02 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 13, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 2126859 Jun 14 2026, 07:02 AM

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Jun 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1004

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1004

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1004

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1004

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1004

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1004

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1004

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1004

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1004

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1004

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1004

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1004

executor

npm i https://pkg.pr.new/executor@1004

commit: 2126859

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces executor service install | uninstall | status | restart — a new CLI surface that registers the existing foreground daemon with the OS service manager (launchd on macOS, systemd --user on Linux, Task Scheduler S4U on Windows) so it survives app-quit and machine reboots. It also fixes Windows binary detection in isDevCliEntrypoint and moves resolveToolInvocation (with a new @file input form) from main.ts into tooling.ts.

  • service.ts (new, 688 lines): Three platform backends generate unit files/wrappers (with XML-escaping, cmd-metachar sanitisation, PowerShell -EncodedCommand encoding) and drive launchctl/systemctl/PowerShell to register, start, and query the service. Auth credentials are never written to any unit — the daemon loads them from auth.json at startup.
  • main.ts: Wires up the four service sub-commands and changes the supervised-daemon startup path to unconditionally remove a stale server.json (via EXECUTOR_SUPERVISED=1) rather than running the "is another instance running?" check that would crash-loop under KeepAlive.
  • daemon.ts / tooling.ts: Targeted bug fixes — Windows bunfs path detection and @file JSON input — each covered by new unit tests.

Confidence Score: 4/5

The core install logic, credential isolation, and supervised startup path are correct, but bugs already flagged in review threads remain unaddressed in service.ts.

The systemd Environment= generator emits bare key=value lines without quoting values that contain whitespace — a PATH entry like /home/user/my tools/bin would be misparsed into two separate broken assignments at unit load time. That bug is directly visible in the generateSystemdUnit diff and is not fixed here. Similarly, readServiceKey catch-all error swallowing is still present in local-server-manifest.ts. Both are real defects on currently-shipped code paths.

apps/cli/src/service.ts — specifically the generateSystemdUnit environment rendering and any code that reads service.key

Important Files Changed

Filename Overview
apps/cli/src/service.ts New 688-line module implementing launchd, systemd, and Windows Task Scheduler backends; open issues from prior review threads (systemd Environment= quoting, readServiceKey error handling) remain unaddressed in this file.
apps/cli/src/main.ts Adds service install/uninstall/status/restart commands and fixes supervised-daemon startup to remove the stale server.json rather than blocking on assertNoOtherActiveLocalServer; logic is correct.
apps/cli/src/daemon.ts Fixes isDevCliEntrypoint to recognise the Windows Bun embedded-filesystem root so service install no longer incorrectly refuses on real Windows binaries.
apps/cli/src/local-server-manifest.ts Adds removeLocalServerManifest() for the supervised-daemon stale-manifest reclaim path; straightforward addition with no side-effects on existing callers.
apps/cli/src/service.test.ts New 148-line test file covering plist generation, systemd unit rendering, Windows wrapper/PowerShell script generation, cmdSetValue sanitisation, and platform dispatch.
apps/cli/src/tooling.ts Moves resolveToolInvocation from main.ts into tooling.ts and extends it with @file input support, correctly requiring FileSystem as an Effect dependency.
apps/cli/src/tooling.test.ts Adds comprehensive tests for resolveToolInvocation including @file reading, inline JSON regression, missing file, non-object file content, and bare @ edge case.
apps/cli/src/daemon.test.ts Adds isDevCliEntrypoint unit tests covering Unix bunfs, Windows bunfs (both slash forms), mid-tree ~BUN directory, and undefined entrypoint.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["executor service install"] --> B{isDevMode?}
    B -- yes --> ERR1["Error: requires compiled binary"]
    B -- no --> C{platform}
    C -- darwin --> D["launchd backend"]
    C -- linux --> E["systemd backend"]
    C -- win32 --> F["Windows Task Scheduler"]
    C -- other --> G["unsupported backend"]
    G --> ERR2["Error: not supported"]
    D & E & F --> H{active server manifest?}
    H -- cli-daemon already running --> I["Print already running"]
    H -- other kind active --> ERR3["Error: stop it first"]
    H -- none --> J["backend.install()"]
    J -- darwin --> K["Write plist 0600 / launchctl bootstrap"]
    J -- linux --> L["Write unit 0600 / systemctl enable --now"]
    J -- win32 --> M["Write run-daemon.cmd / Register-ScheduledTask"]
    K & L & M --> N["waitForReachable port 30s"]
    N -- ok --> O["Print success"]
    N -- timeout --> ERR4["Error: not reachable"]
Loading

Reviews (5): Last reviewed commit: "Add executor service to supervise the lo..." | Re-trigger Greptile

Comment thread apps/cli/src/main.ts Outdated
Comment on lines +2214 to +2222
let servicePassword: string | null = null;
if (!noAuth) {
const explicit = Option.getOrUndefined(password);
const existing = yield* readServiceKey();
servicePassword = explicit ?? existing?.password ?? generateServicePassword();
yield* writeServiceKey({ password: servicePassword, createdAt: new Date().toISOString() });
}

yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale service.key when reinstalling with --no-auth

When --no-auth is passed, the block that writes the service key is skipped entirely — but an existing key from a prior install is never removed. On the next start, the supervised daemon reads service.key whenever EXECUTOR_SUPERVISED=1 is set and neither a flag password nor EXECUTOR_AUTH_PASSWORD is present. That means a reinstall with --no-auth silently leaves the daemon protected by the old password the user explicitly asked to remove, giving the impression the service is unauthenticated while it actually requires a credential clients no longer have.

Fix: add yield* removeServiceKey().pipe(Effect.ignore) inside the if (noAuth) branch (or just before the backend install).

Comment thread apps/cli/src/service.ts
Comment on lines +345 to +347
const env = Object.entries(options.environment)
.map(([key, value]) => `Environment=${key}=${value}`)
.join("\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unquoted Environment= values in the systemd unit

systemd tokenizes each Environment= line with shell-style rules — values containing whitespace (e.g. a PATH entry with a spaced directory) would be split at the space and misparsed as separate assignments. The execStart logic above already handles this by wrapping spaced args in double quotes; the same treatment is needed for env values.

Suggested change
const env = Object.entries(options.environment)
.map(([key, value]) => `Environment=${key}=${value}`)
.join("\n");
const env = Object.entries(options.environment)
.map(([key, value]) => {
const needsQuotes = /[\s"\\]/.test(value);
const escaped = needsQuotes ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : value;
return `Environment=${key}=${needsQuotes ? `"${escaped}"` : escaped}`;
})
.join("\n");

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/cli/src/local-server-manifest.ts Outdated
Comment on lines +189 to +191
const raw = yield* fs
.readFileString(serviceKeyPath(path))
.pipe(Effect.catchCause(() => Effect.succeed(null)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security readServiceKey uses Effect.catchCause, which swallows every possible cause — including a PermissionDenied error if service.key is made unreadable (e.g. by a permissions-resetting backup restore or a chmod 000). When the file exists but can't be read, the function silently returns null, the keyPassword in daemonRunCommand resolves to undefined, and the supervised daemon starts with no authentication — silently undoing the auth that was in place before. Only "not found" should be treated as "no key"; any other error should propagate so the operator knows something is wrong.

Suggested change
const raw = yield* fs
.readFileString(serviceKeyPath(path))
.pipe(Effect.catchCause(() => Effect.succeed(null)));
const raw = yield* fs
.readFileString(serviceKeyPath(path))
.pipe(
Effect.catchTag("SystemError", (e) =>
e.reason === "NotFound" ? Effect.succeed(null) : Effect.fail(e),
),
);

Comment thread apps/cli/src/service.ts Outdated
Comment on lines +485 to +489
export const windowsScheduledTaskCommand = (descriptor: ServiceDescriptor): string =>
[
"schtasks /Create /TN ExecutorDaemon /SC ONLOGON /RL LIMITED /F",
`/TR "'${descriptor.executablePath}' daemon run --foreground --port ${descriptor.port}"`,
].join(" ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The /TR value wraps the executable path in single quotes ('path'). On Windows, single quotes are not a quoting mechanism — cmd.exe and schtasks treat them as literal characters. For the default install location C:\Program Files\Executor\executor.exe (a path with a space), schtasks would see 'C:\Program as the program name and fail silently. Double-quote escaping is required for Windows path quoting in /TR strings.

Suggested change
export const windowsScheduledTaskCommand = (descriptor: ServiceDescriptor): string =>
[
"schtasks /Create /TN ExecutorDaemon /SC ONLOGON /RL LIMITED /F",
`/TR "'${descriptor.executablePath}' daemon run --foreground --port ${descriptor.port}"`,
].join(" ");
export const windowsScheduledTaskCommand = (descriptor: ServiceDescriptor): string => {
// Escape any embedded double-quotes in the path, then wrap in double-quotes
// so schtasks handles paths that contain spaces (e.g. C:\Program Files\...).
const quotedPath = `"${descriptor.executablePath.replaceAll('"', '\\"')}"`;
return [
"schtasks /Create /TN ExecutorDaemon /SC ONLOGON /RL LIMITED /F",
`/TR "${quotedPath} daemon run --foreground --port ${descriptor.port}"`,
].join(" ");
};

@RhysSullivan RhysSullivan changed the base branch from daemon/busy-timeout to main June 14, 2026 07:00
@RhysSullivan RhysSullivan merged commit 2c1be33 into main Jun 14, 2026
18 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant