Unified · Split
| @@ -3,7 +3,38 @@ import { createServer, type Server } from "node:http"; · 2 unmodified lines | |||
| 3 | import type { AddressInfo } from "node:net"; | 3 | import type { AddressInfo } from "node:net"; |
| 4 | import * as Effect from "effect/Effect"; | 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 | describe("canAutoStartLocalDaemonForHost", () => { | 39 | describe("canAutoStartLocalDaemonForHost", () => { |
| 9 | it("allows loopback hosts", () => { | 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 | const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | 55 | const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| 56 | const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/"; | ||
| 57 | 56 | ||
| 58 | export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => | 57 | export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => |
| 59 | LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); | 58 | LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); |
| 60 | 59 | ||
| 60 | /** | ||
| 61 | * Bun's compiled-binary embedded filesystem root, drive-rooted on Windows | ||
| 62 | * (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a | ||
| 63 | * drive prefix so a dev checkout that merely *contains* a `~BUN` directory | ||
| 64 | * isn't misread as a compiled binary. | ||
| 65 | */ | ||
| 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 | export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => { | 77 | export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => { |
| 62 | if (!scriptPath) return false; | 78 | if (!scriptPath) return false; |
| 63 | if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false; | 79 | const normalized = scriptPath.replaceAll("\\", "/"); |
| 64 | return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js"); | 80 | if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false; |
| 81 | return normalized.endsWith(".ts") || normalized.endsWith(".js"); | ||
| 65 | }; | 82 | }; |
| 66 | 83 | ||
| 67 | export const isExecutorServerReachable = ( | 84 | export const isExecutorServerReachable = ( |
| @@ -76,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: { · 75 unmodified lines | |||
| 76 | yield* fs.remove(manifestPath, { force: true }); | 76 | yield* fs.remove(manifestPath, { force: true }); |
| 77 | }); | 77 | }); |
| 78 | 78 | ||
| 79 | /** | ||
| 80 | * Remove the server manifest unconditionally. Used by an OS-supervised daemon | ||
| 81 | * to reclaim a stale `server.json` left by a previous boot: across a reboot the | ||
| 82 | * recorded pid is meaningless (pids recycle, so it may now belong to an | ||
| 83 | * unrelated process), and launchd/systemd already guarantee a single supervised | ||
| 84 | * instance — so any pre-existing manifest is stale and the supervised daemon | ||
| 85 | * owns it. | ||
| 86 | */ | ||
| 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 | const StartupLockPayload = Schema.Struct({ | 98 | const StartupLockPayload = Schema.Struct({ |
| 80 | pid: Schema.Number, | 99 | pid: Schema.Number, |
| 81 | }); | 100 | }); |
| @@ -100,10 +100,12 @@ import { · 99 unmodified lines | |||
| 100 | acquireLocalServerStartLock, | 100 | acquireLocalServerStartLock, |
| 101 | readLocalServerManifest, | 101 | readLocalServerManifest, |
| 102 | releaseLocalServerStartLock, | 102 | releaseLocalServerStartLock, |
| 103 | removeLocalServerManifest, | ||
| 103 | removeLocalServerManifestIfOwnedBy, | 104 | removeLocalServerManifestIfOwnedBy, |
| 104 | resolveExecutorDataDir, | 105 | resolveExecutorDataDir, |
| 105 | writeLocalServerManifest, | 106 | writeLocalServerManifest, |
| 106 | } from "./local-server-manifest"; | 107 | } from "./local-server-manifest"; |
| 108 | import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service"; | ||
| 107 | import { | 109 | import { |
| 108 | defaultCliServerConnectionProfile, | 110 | defaultCliServerConnectionProfile, |
| 109 | findCliServerConnectionProfile, | 111 | findCliServerConnectionProfile, |
| @@ -115,7 +117,6 @@ import { · 5 unmodified lines | |||
| 115 | } from "./server-profile"; | 117 | } from "./server-profile"; |
| 116 | import { | 118 | import { |
| 117 | buildResumeContentTemplate, | 119 | buildResumeContentTemplate, |
| 118 | buildToolPath, | ||
| 119 | buildDescribeToolCode, | 120 | buildDescribeToolCode, |
| 120 | filterToolPathChildren, | 121 | filterToolPathChildren, |
| 121 | buildInvokeToolCode, | 122 | buildInvokeToolCode, |
| @@ -127,6 +128,7 @@ import { · 5 unmodified lines | |||
| 127 | inspectToolPath, | 128 | inspectToolPath, |
| 128 | normalizeCliErrorText, | 129 | normalizeCliErrorText, |
| 129 | parseJsonObjectInput, | 130 | parseJsonObjectInput, |
| 131 | resolveToolInvocation, | ||
| 130 | sanitizeCliOutputText, | 132 | sanitizeCliOutputText, |
| 131 | shellQuoteArg, | 133 | shellQuoteArg, |
| 132 | } from "./tooling"; | 134 | } from "./tooling"; |
| @@ -886,7 +888,19 @@ const runDaemonSession = (input: { · 753 unmodified lines | |||
| 886 | let token: string | null = null; | 888 | let token: string | null = null; |
| 887 | 889 | ||
| 888 | try { | 890 | try { |
| 889 | yield* assertNoOtherActiveLocalServer(); | 891 | // A supervised daemon (launchd/systemd) is the OS-guaranteed singleton |
| 892 | // — kickstart -k kills the old instance before starting the new — so any | ||
| 893 | // server.json from a previous boot is stale. Reclaim it rather than | ||
| 894 | // refusing: across a reboot the recorded pid may have been recycled by | ||
| 895 | // an unrelated process, which would otherwise make the "is one already | ||
| 896 | // running?" check treat it as alive-but-unreachable, refuse to start, | ||
| 897 | // and crash-loop under KeepAlive. (Found by a real reboot test with | ||
| 898 | // integration data in the DB.) | ||
| 899 | if (process.env.EXECUTOR_SUPERVISED) { | ||
| 900 | yield* removeLocalServerManifest().pipe(Effect.ignore); | ||
| 901 | } else { | ||
| 902 | yield* assertNoOtherActiveLocalServer(); | ||
| 903 | } | ||
| 890 | 904 | ||
| 891 | const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); | 905 | const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); |
| 892 | 906 | ||
| @@ -1572,38 +1586,6 @@ const runCallHelp = ( · 679 unmodified lines | |||
| 1572 | }); | 1586 | }); |
| 1573 | }).pipe(Effect.mapError(toError)); | 1587 | }).pipe(Effect.mapError(toError)); |
| 1574 | 1588 | ||
| 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 | // Commands | 1590 | // Commands |
| 1609 | // --------------------------------------------------------------------------- | 1591 | // --------------------------------------------------------------------------- |
| @@ -2000,6 +1982,10 @@ const daemonRunCommand = Command.make( · 390 unmodified lines | |||
| 2000 | Effect.gen(function* () { | 1982 | Effect.gen(function* () { |
| 2001 | applyScope(scope); | 1983 | applyScope(scope); |
| 2002 | if (foreground) { | 1984 | if (foreground) { |
| 1985 | // The foreground daemon is the form OS service managers run. Its bearer | ||
| 1986 | // comes from --auth-token, else the stable token in auth.json (loaded by | ||
| 1987 | // startServer from EXECUTOR_DATA_DIR) — the supervised unit carries no | ||
| 1988 | // secret, so the daemon and its clients share the one auth.json token. | ||
| 2003 | yield* runDaemonSession({ | 1989 | yield* runDaemonSession({ |
| 2004 | port, | 1990 | port, |
| 2005 | hostname, | 1991 | hostname, |
| @@ -2116,6 +2102,144 @@ const mcpCommand = Command.make( · 110 unmodified lines | |||
| 2116 | }), | 2102 | }), |
| 2117 | ).pipe(Command.withDescription("Start an MCP server over stdio")); | 2103 | ).pipe(Command.withDescription("Start an MCP server over stdio")); |
| 2118 | 2104 | ||
| 2105 | // --------------------------------------------------------------------------- | ||
| 2106 | // Service — register the daemon with the OS so it survives app-quit + restart | ||
| 2107 | // --------------------------------------------------------------------------- | ||
| 2108 | |||
| 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 | // Root command | 2244 | // Root command |
| 2121 | // --------------------------------------------------------------------------- | 2245 | // --------------------------------------------------------------------------- |
| @@ -2170,6 +2294,7 @@ const root = Command.make("executor").pipe( · 48 unmodified lines | |||
| 2170 | serverCommand, | 2294 | serverCommand, |
| 2171 | webCommand, | 2295 | webCommand, |
| 2172 | daemonCommand, | 2296 | daemonCommand, |
| 2297 | serviceCommand, | ||
| 2173 | mcpCommand, | 2298 | mcpCommand, |
| 2174 | openCommand, | 2299 | openCommand, |
| 2175 | ] as const), | 2300 | ] as const), |
| @@ -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 | import { describe, expect, it } from "@effect/vitest"; | 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 | describe("shellQuoteArg", () => { | 80 | describe("shellQuoteArg", () => { |
| 6 | it("quotes single quotes without breaking the shell argument", () => { | 81 | it("quotes single quotes without breaking the shell argument", () => { |
| @@ -1,3 +1,4 @@ | |||
| 1 | import { FileSystem } from "effect"; | ||
| 1 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | ||
| 3 | const isRecord = (value: unknown): value is Record<string, unknown> => | 4 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| @@ -145,6 +146,65 @@ export const parseJsonObjectInput = ( · 141 unmodified lines | |||
| 145 | return parsed; | 146 | return parsed; |
| 146 | }); | 147 | }); |
| 147 | 148 | ||
| 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 | export const extractExecutionResult = (structured: unknown): unknown => { | 208 | export const extractExecutionResult = (structured: unknown): unknown => { |
| 149 | if (!isRecord(structured) || !("result" in structured)) { | 209 | if (!isRecord(structured) || !("result" in structured)) { |
| 150 | return null; | 210 | return null; |