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