Add executor service to supervise the local gateway daemon#1004
Conversation
Deploying with
|
| 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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | 2126859 | Jun 14 2026, 07:02 AM |
Cloudflare previewTorn down — the PR is closed. |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
6069b98 to
ec96a52
Compare
3f5d776 to
6b0d8d1
Compare
Greptile SummaryThis PR introduces
Confidence Score: 4/5The 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
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"]
Reviews (5): Last reviewed commit: "Add executor service to supervise the lo..." | Re-trigger Greptile |
| 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 }); |
There was a problem hiding this comment.
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).
| const env = Object.entries(options.environment) | ||
| .map(([key, value]) => `Environment=${key}=${value}`) | ||
| .join("\n"); |
There was a problem hiding this comment.
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.
| 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!
ec96a52 to
34b2c67
Compare
61480bc to
6aad883
Compare
| const raw = yield* fs | ||
| .readFileString(serviceKeyPath(path)) | ||
| .pipe(Effect.catchCause(() => Effect.succeed(null))); |
There was a problem hiding this comment.
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.
| 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), | |
| ), | |
| ); |
| 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(" "); |
There was a problem hiding this comment.
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.
| 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(" "); | |
| }; |
34b2c67 to
0766801
Compare
dd6ae85 to
3f98ede
Compare
3f98ede to
2126859
Compare
Problem
The local MCP gateway is owned by a foreground process. Quit the desktop app (or restart the machine) and the
/mcpendpoint 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.RunAtLoad+KeepAlive={SuccessfulExit:false}(restart on crash, stay stopped on a cleanbootout).systemd --userunit (Restart=on-failure,WantedBy=default.target).Design notes:
0600service.keythat the supervised daemon reads on start, solaunchctl print/listandsystemctl catnever expose it.service installruns from the user's shell, so its PATH is the right one to capture.service status(running daemon's version vs the installed CLI) so an upgrade that left the unit pointing at an old binary is visible.installrefuses to fight an already-running local server and polls until the daemon is reachable.Verification
0600service.keyround-trip.install→GET /returns 200 →restart(kickstart) → still 200 →uninstall→ endpoint refused, plist removed. ConfirmedEXECUTOR_AUTH_PASSWORDis absent from the plist andservice.keyis mode600.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
executor serviceto supervise the local gateway daemon #1004 👈 current