From 21268596a38f43607c4e7b01c11a3d9f24ef9226 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sat, 13 Jun 2026 23:24:16 -0700 Subject: [PATCH] Add executor service to supervise the local gateway daemon --- apps/cli/src/daemon.test.ts | 33 +- apps/cli/src/daemon.ts | 23 +- apps/cli/src/local-server-manifest.ts | 19 + apps/cli/src/main.ts | 193 ++++++-- apps/cli/src/service.test.ts | 148 ++++++ apps/cli/src/service.ts | 688 ++++++++++++++++++++++++++ apps/cli/src/tooling.test.ts | 77 ++- apps/cli/src/tooling.ts | 60 +++ 8 files changed, 1202 insertions(+), 39 deletions(-) create mode 100644 apps/cli/src/service.test.ts create mode 100644 apps/cli/src/service.ts diff --git a/apps/cli/src/daemon.test.ts b/apps/cli/src/daemon.test.ts index 6005b6a2b..884fd9c7d 100644 --- a/apps/cli/src/daemon.test.ts +++ b/apps/cli/src/daemon.test.ts @@ -3,7 +3,38 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import * as Effect from "effect/Effect"; -import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon"; +import { + canAutoStartLocalDaemonForHost, + isDevCliEntrypoint, + isExecutorServerReachable, +} from "./daemon"; + +describe("isDevCliEntrypoint", () => { + it("treats source entrypoints as dev", () => { + expect(isDevCliEntrypoint("/Users/x/src/executor/apps/cli/src/main.ts")).toBe(true); + expect(isDevCliEntrypoint("/Users/x/dist/main.js")).toBe(true); + }); + + it("treats compiled single-file binaries as NOT dev (both Unix and Windows)", () => { + // Bun's embedded filesystem: `/$bunfs/...` on Unix, `B:\~BUN\...` on Windows. + // Missing the Windows form made a real `executor.exe` look like a dev + // checkout, so `service install` wrongly refused on Windows. + expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false); + expect(isDevCliEntrypoint("B:/~BUN/root/main.js")).toBe(false); + expect(isDevCliEntrypoint("B:\\~BUN\\root\\main.js")).toBe(false); + }); + + it("only treats a DRIVE-ROOTED ~BUN as compiled (a ~BUN dir mid-tree stays dev)", () => { + // The Windows bunfs root is `:\~BUN\...`; a dev checkout that merely + // contains a `~BUN` directory must not be misread as a compiled binary. + expect(isDevCliEntrypoint("/home/user/~BUN/project/src/main.ts")).toBe(true); + expect(isDevCliEntrypoint("C:/Users/dev/~BUN/src/main.ts")).toBe(true); + }); + + it("is false when no entrypoint is known", () => { + expect(isDevCliEntrypoint(undefined)).toBe(false); + }); +}); describe("canAutoStartLocalDaemonForHost", () => { it("allows loopback hosts", () => { diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 087b7ab99..ec05e95e8 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -53,15 +53,32 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed // --------------------------------------------------------------------------- const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); -const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/"; export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); +/** + * Bun's compiled-binary embedded filesystem root, drive-rooted on Windows + * (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a + * drive prefix so a dev checkout that merely *contains* a `~BUN` directory + * isn't misread as a compiled binary. + */ +const WINDOWS_BUNFS_ENTRYPOINT = /^[a-z]:\/~BUN\//i; + +/** + * Whether the process is running from the dev source (`bun run src/main.ts`) + * rather than a compiled single-file binary. A compiled binary runs from Bun's + * embedded filesystem, whose entrypoint is `/$bunfs/root/main.js` on Unix but + * `B:\~BUN\root\main.js` (argv like `B:/~BUN/root/main.js`) on Windows — match + * BOTH. Missing the Windows form made a real `executor.exe` look like a dev + * checkout, so `service install` refused on Windows. (Found by a real EC2 + * Windows test.) + */ export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => { if (!scriptPath) return false; - if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false; - return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js"); + const normalized = scriptPath.replaceAll("\\", "/"); + if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false; + return normalized.endsWith(".ts") || normalized.endsWith(".js"); }; export const isExecutorServerReachable = ( diff --git a/apps/cli/src/local-server-manifest.ts b/apps/cli/src/local-server-manifest.ts index 9101dc58b..b0ec989b3 100644 --- a/apps/cli/src/local-server-manifest.ts +++ b/apps/cli/src/local-server-manifest.ts @@ -76,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: { yield* fs.remove(manifestPath, { force: true }); }); +/** + * Remove the server manifest unconditionally. Used by an OS-supervised daemon + * to reclaim a stale `server.json` left by a previous boot: across a reboot the + * recorded pid is meaningless (pids recycle, so it may now belong to an + * unrelated process), and launchd/systemd already guarantee a single supervised + * instance — so any pre-existing manifest is stale and the supervised daemon + * owns it. + */ +export const removeLocalServerManifest = (): Effect.Effect< + void, + PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(localServerManifestPath(path), { force: true }); + }); + const StartupLockPayload = Schema.Struct({ pid: Schema.Number, }); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 3152370da..b0d18c007 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -100,10 +100,12 @@ import { acquireLocalServerStartLock, readLocalServerManifest, releaseLocalServerStartLock, + removeLocalServerManifest, removeLocalServerManifestIfOwnedBy, resolveExecutorDataDir, writeLocalServerManifest, } from "./local-server-manifest"; +import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service"; import { defaultCliServerConnectionProfile, findCliServerConnectionProfile, @@ -115,7 +117,6 @@ import { } from "./server-profile"; import { buildResumeContentTemplate, - buildToolPath, buildDescribeToolCode, filterToolPathChildren, buildInvokeToolCode, @@ -127,6 +128,7 @@ import { inspectToolPath, normalizeCliErrorText, parseJsonObjectInput, + resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg, } from "./tooling"; @@ -886,7 +888,19 @@ const runDaemonSession = (input: { let token: string | null = null; try { - yield* assertNoOtherActiveLocalServer(); + // A supervised daemon (launchd/systemd) is the OS-guaranteed singleton + // — kickstart -k kills the old instance before starting the new — so any + // server.json from a previous boot is stale. Reclaim it rather than + // refusing: across a reboot the recorded pid may have been recycled by + // an unrelated process, which would otherwise make the "is one already + // running?" check treat it as alive-but-unreachable, refuse to start, + // and crash-loop under KeepAlive. (Found by a real reboot test with + // integration data in the DB.) + if (process.env.EXECUTOR_SUPERVISED) { + yield* removeLocalServerManifest().pipe(Effect.ignore); + } else { + yield* assertNoOtherActiveLocalServer(); + } const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); @@ -1572,38 +1586,6 @@ const runCallHelp = ( }); }).pipe(Effect.mapError(toError)); -const resolveToolInvocation = (input: { - rawPathParts: ReadonlyArray; -}): Effect.Effect<{ path: string; args: Record }, Error> => - Effect.gen(function* () { - if (!Array.isArray(input.rawPathParts)) { - return yield* Effect.fail( - new Error("Invalid tool invocation: path parts were not parsed as an array"), - ); - } - - const maybeJsonArg = input.rawPathParts.at(-1)?.trim(); - const hasInlineJsonArg = maybeJsonArg !== undefined && maybeJsonArg.startsWith("{"); - const pathParts = hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts; - const args = hasInlineJsonArg ? yield* parseJsonObjectInput(maybeJsonArg) : {}; - - if (pathParts.some((part) => part.trim().startsWith("-"))) { - return yield* Effect.fail( - new Error( - "Tool invocation no longer accepts flags. Use: executor call '{...json...}'", - ), - ); - } - - const path = yield* Effect.try({ - try: () => buildToolPath(pathParts), - catch: (cause) => - cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), - }); - - return { path, args }; - }); - // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- @@ -2000,6 +1982,10 @@ const daemonRunCommand = Command.make( Effect.gen(function* () { applyScope(scope); if (foreground) { + // The foreground daemon is the form OS service managers run. Its bearer + // comes from --auth-token, else the stable token in auth.json (loaded by + // startServer from EXECUTOR_DATA_DIR) — the supervised unit carries no + // secret, so the daemon and its clients share the one auth.json token. yield* runDaemonSession({ port, hostname, @@ -2116,6 +2102,144 @@ const mcpCommand = Command.make( }), ).pipe(Command.withDescription("Start an MCP server over stdio")); +// --------------------------------------------------------------------------- +// Service — register the daemon with the OS so it survives app-quit + restart +// --------------------------------------------------------------------------- + +const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; + +const serviceInstallCommand = Command.make( + "install", + { + port: Options.integer("port") + .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) + .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")), + }, + ({ port }) => + Effect.gen(function* () { + if (isDevMode) { + return yield* Effect.fail( + new Error( + [ + "`service install` requires the compiled `executor` binary so the OS can run it directly.", + `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`, + ].join("\n"), + ), + ); + } + + const backend = getServiceBackend(); + if (!backend.automated) { + // Unsupported platforms surface their manual steps via the install error. + yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + return; + } + + // Don't fight an already-running local server against the same data dir + // (a desktop sidecar, a foreground `executor web`, or an existing daemon). + const active = yield* readActiveLocalServerManifest(); + if (active) { + const status = yield* backend.status(); + if (status.registered && status.running && active.kind === "cli-daemon") { + console.log( + `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`, + ); + return; + } + return yield* Effect.fail( + new Error( + [ + `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, + `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`, + ].join("\n"), + ), + ); + } + + // The unit carries no secret: the supervised daemon mints/loads its bearer + // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read + // the same file — so reachability is the credential-free /api/health probe. + yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + + const origin = supervisedServiceOrigin(port); + const reachable = yield* waitForReachable({ + check: isServerReachable(origin), + timeoutMs: DAEMON_BOOT_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!reachable) { + return yield* Effect.fail( + new Error( + [ + `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`, + `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, + ].join("\n"), + ), + ); + } + + console.log(`Executor is now running as a background service at ${origin}.`); + console.log("It keeps serving after you quit the app and restarts on login."); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`); + }), +).pipe( + Command.withDescription("Install and start Executor as an OS-supervised background service"), +); + +const serviceUninstallCommand = Command.make("uninstall", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + yield* backend.uninstall(); + console.log("Executor background service uninstalled."); + }), +).pipe(Command.withDescription("Stop and remove the OS-supervised background service")); + +const serviceStatusCommand = Command.make("status", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + const status = yield* backend.status(); + // Tolerate a registered-but-unreachable manifest here — status shouldn't throw. + const active = yield* readActiveLocalServerManifest().pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + console.log(`Platform: ${status.platform}`); + console.log(`Registered: ${status.registered ? "yes" : "no"}`); + console.log( + `Running: ${status.running ? "yes" : "no"}${status.pid ? ` (pid ${status.pid})` : ""}`, + ); + if (active) { + console.log(`Serving: ${active.connection.origin} (${active.kind}, pid ${active.pid})`); + // Version drift: the running daemon was launched by the binary the unit + // points at. If that differs from this CLI, an upgrade left the unit + // pointing at an older binary — reinstall to repoint + restart. + if (active.owner.version && active.owner.version !== CLI_VERSION) { + console.log( + `Drift: running ${active.owner.version}, current ${CLI_VERSION} — run \`${cliPrefix} service install\` to upgrade.`, + ); + } + } + for (const line of status.detail) console.log(line); + }), +).pipe(Command.withDescription("Show the OS-supervised service status")); + +const serviceRestartCommand = Command.make("restart", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + yield* backend.restart(); + console.log("Executor background service restarted."); + }), +).pipe(Command.withDescription("Restart the OS-supervised background service")); + +const serviceCommand = Command.make("service").pipe( + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceStatusCommand, + serviceRestartCommand, + ] as const), + Command.withDescription("Manage the OS-supervised background service"), +); + // --------------------------------------------------------------------------- // Root command // --------------------------------------------------------------------------- @@ -2170,6 +2294,7 @@ const root = Command.make("executor").pipe( serverCommand, webCommand, daemonCommand, + serviceCommand, mcpCommand, openCommand, ] as const), diff --git a/apps/cli/src/service.test.ts b/apps/cli/src/service.test.ts new file mode 100644 index 000000000..0510b4749 --- /dev/null +++ b/apps/cli/src/service.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + cmdSetValue, + generateLaunchdPlist, + generateSystemdUnit, + generateWindowsDaemonWrapper, + generateWindowsRegisterScript, + getServiceBackend, +} from "./service"; + +describe("service unit generation", () => { + const launchdInput = { + label: "sh.executor.daemon", + programArguments: [ + "/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar", + "daemon", + "run", + "--foreground", + "--port", + "4789", + ], + environment: { + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: "/Users/x/.executor", + EXECUTOR_SERVICE_VERSION: "1.5.10", + PATH: "/opt/homebrew/bin:/usr/bin", + }, + stdoutPath: "/Users/x/.executor/logs/daemon.log", + stderrPath: "/Users/x/.executor/logs/daemon.error.log", + workingDirectory: "/Users/x/.executor", + }; + + it("renders a launchd plist that restarts on crash but not clean stop", () => { + const plist = generateLaunchdPlist(launchdInput); + expect(plist).toContain(''); + expect(plist).toContain("Label"); + expect(plist).toContain("sh.executor.daemon"); + expect(plist).toMatch(/RunAtLoad<\/key>\s*/); + // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout. + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("SuccessfulExit"); + expect(plist).toMatch(/SuccessfulExit<\/key>\s*/); + expect(plist).toContain("ProcessType"); + expect(plist).toContain("Background"); + expect(plist).toContain("--foreground"); + expect(plist).toContain("EXECUTOR_SUPERVISED"); + expect(plist).toContain("/Users/x/.executor/logs/daemon.error.log"); + }); + + it("never leaks the auth password into the unit", () => { + const plist = generateLaunchdPlist(launchdInput); + // The secret lives in the 0600 service.key, never in the plist env. + expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("xml-escapes environment values", () => { + const plist = generateLaunchdPlist({ + ...launchdInput, + environment: { PATH: "a&b\"d'" }, + }); + expect(plist).toContain("a&b<c>"d'"); + expect(plist).not.toMatch(/a&b/); + }); + + it("renders a systemd --user unit with crash-only restart", () => { + const unit = generateSystemdUnit({ + execStart: ["/usr/local/bin/executor", "daemon", "run", "--foreground", "--port", "4789"], + environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, + workingDirectory: "/home/x/.executor", + stdoutPath: "/home/x/.executor/logs/daemon.log", + stderrPath: "/home/x/.executor/logs/daemon.error.log", + }); + expect(unit).toContain("ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789"); + expect(unit).toContain("Restart=on-failure"); + expect(unit).toContain("WantedBy=default.target"); + expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); + expect(unit).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("bakes the supervised env into the Windows wrapper .cmd", () => { + const wrapper = generateWindowsDaemonWrapper( + { + executablePath: "C:\\Program Files\\Executor\\executor.exe", + port: 4789, + version: "1.5.10", + }, + "C:\\Users\\x\\.executor", + "C:\\Users\\x\\.executor\\logs", + ); + // Task Scheduler can't set env, so it rides as `set` lines in the wrapper. + expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); + expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); + expect(wrapper).toContain( + '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789', + ); + expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); + // The secret is never baked into the wrapper — the daemon reads service.key. + expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { + // A `"` in PATH would close the `set "PATH=..."` quote early and let a + // `& cmd &` fragment run at boot as the user; strip it (illegal in a path + // anyway). A `%` would re-expand against the boot environment; double it. + expect(cmdSetValue('C:\\a" & evil & "C:\\b')).toBe("C:\\a & evil & C:\\b"); + expect(cmdSetValue("C:\\tools\\%LOCALAPPDATA%\\bin")).toBe("C:\\tools\\%%LOCALAPPDATA%%\\bin"); + expect(cmdSetValue("C:\\Program Files\\node")).toBe("C:\\Program Files\\node"); + // The sanitized value, embedded in a `set` line, can't break out of quotes. + expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/); + }); + + it("registers a boot-triggered S4U task (the reboot-survival contract)", () => { + const script = generateWindowsRegisterScript({ + taskName: "ExecutorDaemon", + wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd", + userId: "x", + }); + // S4U + AtStartup = run as the user, at boot, no stored password, no logon. + expect(script).toContain("-LogonType S4U"); + expect(script).toContain("New-ScheduledTaskTrigger -AtStartup"); + expect(script).toContain("-RestartCount 3"); + expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'"); + expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'"); + }); +}); + +describe("service backend dispatch", () => { + it("selects launchd on macOS (automated)", () => { + const backend = getServiceBackend("darwin"); + expect(backend.platform).toBe("darwin"); + expect(backend.automated).toBe(true); + }); + + it("selects systemd on linux (automated)", () => { + expect(getServiceBackend("linux").platform).toBe("linux"); + }); + + it("selects Task Scheduler on windows (automated)", () => { + const backend = getServiceBackend("win32"); + expect(backend.platform).toBe("win32"); + expect(backend.automated).toBe(true); + }); + + it("falls back to unsupported on other platforms", () => { + expect(getServiceBackend("freebsd").platform).toBe("unsupported"); + }); +}); diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts new file mode 100644 index 000000000..806aec392 --- /dev/null +++ b/apps/cli/src/service.ts @@ -0,0 +1,688 @@ +import { execFile } from "node:child_process"; +import { homedir, userInfo } from "node:os"; +import { FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Effect from "effect/Effect"; + +import { resolveExecutorDataDir } from "./local-server-manifest"; + +// --------------------------------------------------------------------------- +// OS service backends for the supervised Executor daemon. +// +// The long-lived gateway must outlive the GUI app and survive machine restarts. +// That means the OS service manager — not a foreground process — owns its +// lifecycle: launchd on macOS, systemd --user on Linux, Task Scheduler on +// Windows. Each backend registers the SAME running contract: spawn +// ` daemon run --foreground --port

`, bind loopback, write +// `server.json`, and get restarted on crash but not on a clean stop. +// +// macOS (launchd), Linux (systemd --user + lingering), and Windows (Task +// Scheduler S4U/AtStartup) are all reboot-survival verified in real VMs. +// --------------------------------------------------------------------------- + +export const SERVICE_LABEL = "sh.executor.daemon"; + +/** + * The supervised service binds this port by default. It matches the desktop + * connect-card port (4789, not the `executor daemon run` default of 4788) so + * existing desktop MCP-client configs keep resolving. The exact value is + * low-stakes: clients discover the live port from `server.json`. + */ +export const DEFAULT_SERVICE_PORT = 4789; + +export interface ServiceDescriptor { + /** Absolute path to the `executor` binary the service should run. */ + readonly executablePath: string; + readonly port: number; + /** Installing CLI version, baked in for drift detection on upgrade. */ + readonly version: string; +} + +// No secret is part of the descriptor: the supervised daemon mints/loads its +// bearer token from the 0600 `auth.json` (under EXECUTOR_DATA_DIR) on start, and +// clients read the same file. Keeping the secret out of the plist/unit means +// `launchctl print`/`list` and `systemctl cat` never expose it. + +export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported"; + +export interface ServiceStatus { + readonly platform: ServicePlatform; + /** The OS manager has a unit/plist/task on disk for the service. */ + readonly registered: boolean; + /** The OS manager reports the service currently loaded/active. */ + readonly running: boolean; + readonly pid: number | null; + /** Extra human-readable lines (e.g. manual steps on unsupported platforms). */ + readonly detail: ReadonlyArray; +} + +export interface ServiceBackend { + readonly platform: ServicePlatform; + /** True when this backend actually drives the OS manager (vs. printing steps). */ + readonly automated: boolean; + readonly install: ( + descriptor: ServiceDescriptor, + ) => Effect.Effect; + readonly uninstall: () => Effect.Effect< + void, + Error | PlatformError, + FileSystem.FileSystem | Path.Path + >; + readonly status: () => Effect.Effect< + ServiceStatus, + Error | PlatformError, + FileSystem.FileSystem | Path.Path + >; + readonly restart: () => Effect.Effect; +} + +// --------------------------------------------------------------------------- +// Process helper — run an OS command and capture (stdout, stderr, exit code). +// Resolves on a non-zero exit so callers can branch; fails only when the +// command itself cannot be spawned (e.g. launchctl missing). +// --------------------------------------------------------------------------- + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +const runCommand = ( + cmd: string, + args: ReadonlyArray, + env?: Record, +): Effect.Effect => + Effect.callback((resume) => { + const options = env + ? { encoding: "utf8" as const, env: { ...process.env, ...env } } + : { encoding: "utf8" as const }; + execFile(cmd, [...args], options, (error, stdout, stderr) => { + // A string `code` (ENOENT etc.) means the command could not be spawned. + if (error && typeof (error as { code?: unknown }).code === "string") { + resume( + Effect.fail(new Error(`Failed to run \`${cmd}\`: ${(error as { code: string }).code}`)), + ); + return; + } + const code = + error && typeof (error as { code?: unknown }).code === "number" + ? (error as { code: number }).code + : 0; + resume(Effect.succeed({ stdout: stdout ?? "", stderr: stderr ?? "", code })); + }); + }); + +const currentUid = (): number => { + const getuid = (process as { getuid?: () => number }).getuid; + if (typeof getuid === "function") return getuid.call(process); + return userInfo().uid; +}; + +const xmlEscape = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +// --------------------------------------------------------------------------- +// Shared service environment + program args +// --------------------------------------------------------------------------- + +const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray => [ + descriptor.executablePath, + "daemon", + "run", + "--foreground", + "--port", + String(descriptor.port), +]; + +const serviceEnvironment = ( + descriptor: ServiceDescriptor, + dataDir: string, +): Record => ({ + // Marks the process as OS-supervised so the daemon resolves its bearer token + // from the durable 0600 auth.json (the secret is never in the unit itself). + EXECUTOR_SUPERVISED: "1", + // Pin the data dir explicitly: launchd/systemd give a minimal environment and + // we never want the daemon to fall back to a different home than the user's. + EXECUTOR_DATA_DIR: dataDir, + // Stamp the installing version so `service status` can flag drift after an + // upgrade where the unit still points at an older binary path. + EXECUTOR_SERVICE_VERSION: descriptor.version, + // A launchd/systemd unit starts with a bare PATH — without the user's PATH + // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may + // shell out to. `service install` runs from the user's shell, so its own + // PATH is the right one to bake in. (Reference: opencode shell-env capture.) + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), +}); + +// --------------------------------------------------------------------------- +// macOS — launchd LaunchAgent (fully built) +// --------------------------------------------------------------------------- + +const launchAgentsDir = (path: Path.Path): string => + path.join(homedir(), "Library", "LaunchAgents"); + +const launchdPlistPath = (path: Path.Path): string => + path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`); + +const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); + +export interface LaunchdPlistOptions { + readonly label: string; + readonly programArguments: ReadonlyArray; + readonly environment: Record; + readonly stdoutPath: string; + readonly stderrPath: string; + readonly workingDirectory: string; +} + +/** + * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses + * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero + * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → + * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background + * keeps it off the foreground scheduler. + */ +export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { + const programArgs = options.programArguments + .map((arg) => ` ${xmlEscape(arg)}`) + .join("\n"); + const envEntries = Object.entries(options.environment) + .map( + ([key, value]) => + ` ${xmlEscape(key)}\n ${xmlEscape(value)}`, + ) + .join("\n"); + return ` + + + + Label + ${xmlEscape(options.label)} + ProgramArguments + +${programArgs} + + EnvironmentVariables + +${envEntries} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + WorkingDirectory + ${xmlEscape(options.workingDirectory)} + StandardOutPath + ${xmlEscape(options.stdoutPath)} + StandardErrorPath + ${xmlEscape(options.stderrPath)} + + +`; +}; + +const parseLaunchctlPid = (printOutput: string): number | null => { + const match = printOutput.match(/\bpid\s*=\s*(\d+)/); + if (!match) return null; + const pid = Number.parseInt(match[1], 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; +}; + +const makeLaunchdBackend = (): ServiceBackend => { + const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`; + + return { + platform: "darwin", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + + yield* fs.makeDirectory(launchAgentsDir(path), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + + const plist = generateLaunchdPlist({ + label: SERVICE_LABEL, + programArguments: serviceProgramArguments(descriptor), + environment: serviceEnvironment(descriptor, dataDir), + stdoutPath: path.join(logs, "daemon.log"), + stderrPath: path.join(logs, "daemon.error.log"), + workingDirectory: dataDir, + }); + const plistFile = launchdPlistPath(path); + // 0600: the plist is owner-only. It carries no secret — the daemon reads + // the bearer from auth.json at boot — but stays tight regardless. + yield* fs.writeFileString(plistFile, plist, { mode: 0o600 }); + + // Re-bootstrap cleanly: a stale registration from a prior install would + // make `bootstrap` fail with "service already loaded". + yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); + const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]); + if (bootstrap.code !== 0) { + return yield* Effect.fail( + new Error( + `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`, + ), + ); + } + yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore); + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); + yield* runCommand("launchctl", ["disable", serviceTarget(uid)]).pipe(Effect.ignore); + yield* fs.remove(launchdPlistPath(path), { force: true }); + }), + status: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + const registered = yield* fs.exists(launchdPlistPath(path)); + const print = yield* runCommand("launchctl", ["print", serviceTarget(uid)]); + const running = print.code === 0; + return { + platform: "darwin" as const, + registered, + running, + pid: running ? parseLaunchctlPid(print.stdout) : null, + detail: [], + }; + }), + restart: () => + Effect.gen(function* () { + const uid = currentUid(); + const result = yield* runCommand("launchctl", ["kickstart", "-k", serviceTarget(uid)]); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `launchctl kickstart failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`, + ), + ); + } + }), + }; +}; + +// --------------------------------------------------------------------------- +// Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM) +// --------------------------------------------------------------------------- + +const systemdUnitDir = (path: Path.Path): string => + path.join(homedir(), ".config", "systemd", "user"); + +const systemdUnitPath = (path: Path.Path): string => + path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`); + +export interface SystemdUnitOptions { + readonly execStart: ReadonlyArray; + readonly environment: Record; + readonly workingDirectory: string; + readonly stdoutPath: string; + readonly stderrPath: string; +} + +/** Render a systemd --user unit. Pure (snapshot-tested). */ +export const generateSystemdUnit = (options: SystemdUnitOptions): string => { + const execStart = options.execStart.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); + const env = Object.entries(options.environment) + .map(([key, value]) => `Environment=${key}=${value}`) + .join("\n"); + return `[Unit] +Description=Executor supervised daemon +After=default.target + +[Service] +Type=simple +ExecStart=${execStart} +${env} +WorkingDirectory=${options.workingDirectory} +StandardOutput=append:${options.stdoutPath} +StandardError=append:${options.stderrPath} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=default.target +`; +}; + +const makeSystemdBackend = (): ServiceBackend => { + const unitName = `${SERVICE_LABEL}.service`; + return { + platform: "linux", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + yield* fs.makeDirectory(systemdUnitDir(path), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + const unit = generateSystemdUnit({ + execStart: serviceProgramArguments(descriptor), + environment: serviceEnvironment(descriptor, dataDir), + workingDirectory: dataDir, + stdoutPath: path.join(logs, "daemon.log"), + stderrPath: path.join(logs, "daemon.error.log"), + }); + yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 }); + // `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply + // it if the caller's environment lacks it (e.g. a non-login shell) so + // install is robust regardless of how it was invoked. + const username = userInfo().username; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); + const enable = yield* runCommand( + "systemctl", + ["--user", "enable", "--now", unitName], + sdEnv, + ); + if (enable.code !== 0) { + return yield* Effect.fail( + new Error( + `systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`, + ), + ); + } + // Enable lingering so the user manager — and this enabled service — + // starts at BOOT, not just on login, so the daemon survives a reboot + // unattended (verified in a real Ubuntu VM via loginctl). Best-effort: + // if the platform needs privilege, the service still works for the + // logged-in case and `service status` flags the missing linger. + yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore); + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe( + Effect.ignore, + ); + yield* fs.remove(systemdUnitPath(path), { force: true }); + yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); + yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe( + Effect.ignore, + ); + }), + status: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + const registered = yield* fs.exists(systemdUnitPath(path)); + const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv); + const running = active.stdout.trim() === "active"; + const linger = yield* runCommand("loginctl", [ + "show-user", + userInfo().username, + "-p", + "Linger", + "--value", + ]); + const lingerOn = linger.stdout.trim() === "yes"; + return { + platform: "linux" as const, + registered, + running, + pid: null, + detail: lingerOn + ? [] + : [ + "Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.", + ], + }; + }), + restart: () => + Effect.gen(function* () { + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `systemctl --user restart failed (exit ${result.code}): ${result.stderr.trim()}`, + ), + ); + } + }), + }; +}; + +// --------------------------------------------------------------------------- +// Windows — Task Scheduler (S4U / AtStartup; reboot-survival verified) +// --------------------------------------------------------------------------- + +/** Scheduled Task name registered for the supervised daemon. */ +export const WINDOWS_TASK_NAME = "ExecutorDaemon"; + +/** + * Make a value safe to embed in a cmd.exe `set "KEY=VALUE"` line. A literal `"` + * would close the quoted argument early — in PATH (built from arbitrary + * installer entries) that lets `& cmd &` fragments execute when Task Scheduler + * runs the wrapper at boot, so strip them (a `"` is illegal in a Windows path + * anyway). A literal `%` is re-expanded by cmd at run time against the boot + * environment, silently diverging from the value captured at install; double it + * so the daemon sees exactly what was captured. + */ +export const cmdSetValue = (value: string): string => + value.replaceAll('"', "").replaceAll("%", "%%"); + +/** + * The batch wrapper the Scheduled Task executes. Task Scheduler has no field + * for environment variables, so the supervised env (EXECUTOR_SUPERVISED, data + * dir, version, PATH) is baked into the wrapper as `set` lines before it execs + * the daemon. stdout/stderr append to the same log files the other backends + * use. CRLF line endings keep it a well-formed `.cmd`. + */ +export const generateWindowsDaemonWrapper = ( + descriptor: ServiceDescriptor, + dataDir: string, + logDir: string, +): string => { + const env = serviceEnvironment(descriptor, dataDir); + const setLines = Object.entries(env).map(([key, value]) => `set "${key}=${cmdSetValue(value)}"`); + const [exe, ...rest] = serviceProgramArguments(descriptor); + const command = `"${exe}" ${rest.join(" ")} 1>> "${logDir}\\daemon.log" 2>> "${logDir}\\daemon.error.log"`; + return ["@echo off", ...setLines, command, ""].join("\r\n"); +}; + +/** Quote a value as a PowerShell single-quoted string literal. */ +const psSingleQuote = (value: string): string => `'${value.replaceAll("'", "''")}'`; + +/** + * PowerShell that registers the daemon as a boot-triggered Scheduled Task. + * + * LogonType=S4U + AtStartup is the Windows equivalent of launchd RunAtLoad and + * systemd lingering: the task runs the daemon AS THE USER, at boot, with no + * stored password and no interactive logon — verified to survive a real reboot + * with no login on a headless host. RestartCount/RestartInterval supply the + * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time + * out a long-running task". Registering a boot task requires an elevated + * (Administrator) shell. + */ +export const generateWindowsRegisterScript = (options: { + readonly taskName: string; + readonly wrapperPath: string; + readonly userId: string; +}): string => + [ + `$action = New-ScheduledTaskAction -Execute ${psSingleQuote(options.wrapperPath)}`, + `$trigger = New-ScheduledTaskTrigger -AtStartup`, + `$principal = New-ScheduledTaskPrincipal -UserId ${psSingleQuote(options.userId)} -LogonType S4U -RunLevel Highest`, + `$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)`, + `Register-ScheduledTask -TaskName ${psSingleQuote(options.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`, + `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, + ].join("\n"); + +/** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ +const runPowerShell = (script: string): Effect.Effect => + runCommand("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ]); + +const makeWindowsBackend = (): ServiceBackend => ({ + platform: "win32", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + const control = path.join(dataDir, "server-control"); + yield* fs.makeDirectory(logs, { recursive: true }); + yield* fs.makeDirectory(control, { recursive: true }); + + const wrapperPath = path.join(control, "run-daemon.cmd"); + yield* fs.writeFileString( + wrapperPath, + generateWindowsDaemonWrapper(descriptor, dataDir, logs), + ); + + const result = yield* runPowerShell( + generateWindowsRegisterScript({ + taskName: WINDOWS_TASK_NAME, + wrapperPath, + userId: userInfo().username, + }), + ); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + const hint = /denied|0x80070005|administrator|elevat/i.test(detail) + ? " Run `executor service install` from an Administrator PowerShell." + : ""; + return yield* Effect.fail( + new Error(`Register-ScheduledTask failed (exit ${result.code}): ${detail}.${hint}`), + ); + } + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Tolerate "task not found" (idempotent uninstall), but don't hide a + // failure to even spawn PowerShell — that would leave the task registered + // while we report success. + yield* runPowerShell( + `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + + `Unregister-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`, + ).pipe( + Effect.tapError((cause) => + Effect.sync(() => + console.warn( + `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, + ), + ), + ), + Effect.ignore, + ); + const control = path.join(resolveExecutorDataDir(path), "server-control"); + yield* fs.remove(path.join(control, "run-daemon.cmd"), { force: true }); + }), + status: () => + Effect.gen(function* () { + const result = yield* runPowerShell( + `$t = Get-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue; ` + + `if ($null -eq $t) { 'NONE' } else { 'STATE=' + $t.State }`, + ); + const out = result.stdout.trim(); + if (result.code !== 0 || out === "" || out.includes("NONE")) { + return { + platform: "win32" as const, + registered: false, + running: false, + pid: null, + detail: ["No ExecutorDaemon scheduled task registered. Run `executor service install`."], + }; + } + const state = /STATE=(\w+)/.exec(out)?.[1] ?? "Unknown"; + const running = state === "Running"; + return { + platform: "win32" as const, + registered: true, + running, + pid: null, + detail: running ? [] : [`Scheduled task registered; current state: ${state}.`], + }; + }), + restart: () => + Effect.gen(function* () { + const result = yield* runPowerShell( + `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + + `Start-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)}`, + ); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `Failed to restart ExecutorDaemon task (exit ${result.code}): ${result.stderr.trim()}`, + ), + ); + } + }), +}); + +const makeUnsupportedBackend = (): ServiceBackend => ({ + platform: "unsupported", + automated: false, + install: () => + Effect.fail(new Error(`OS service install is not supported on ${process.platform}.`)), + uninstall: () => + Effect.fail(new Error(`OS service uninstall is not supported on ${process.platform}.`)), + status: () => + Effect.succeed({ + platform: "unsupported" as const, + registered: false, + running: false, + pid: null, + detail: [`OS service management is not supported on ${process.platform}.`], + }), + restart: () => + Effect.fail(new Error(`OS service restart is not supported on ${process.platform}.`)), +}); + +/** Select the service backend for the current OS. */ +export const getServiceBackend = (platform: NodeJS.Platform = process.platform): ServiceBackend => { + switch (platform) { + case "darwin": + return makeLaunchdBackend(); + case "linux": + return makeSystemdBackend(); + case "win32": + return makeWindowsBackend(); + default: + return makeUnsupportedBackend(); + } +}; diff --git a/apps/cli/src/tooling.test.ts b/apps/cli/src/tooling.test.ts index f45fe2d8d..679a487b1 100644 --- a/apps/cli/src/tooling.test.ts +++ b/apps/cli/src/tooling.test.ts @@ -1,6 +1,81 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Effect from "effect/Effect"; -import { sanitizeCliOutputText, shellQuoteArg } from "./tooling"; +import { resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg } from "./tooling"; + +const withTmp = (body: (dir: string) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-call-"))), + body, + (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })), + ); + +describe("resolveToolInvocation", () => { + it.effect("reads the JSON arg from a file via @path", () => + withTmp((dir) => + Effect.gen(function* () { + const file = join(dir, "input.json"); + writeFileSync(file, '{"title":"Hi","n":2}'); + const result = yield* resolveToolInvocation({ + rawPathParts: ["github", "issues", "create", `@${file}`], + }); + expect(result.path).toBe("github.issues.create"); + expect(result.args).toEqual({ title: "Hi", n: 2 }); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("still accepts inline JSON (slice condition regression)", () => + Effect.gen(function* () { + const result = yield* resolveToolInvocation({ + rawPathParts: ["github", "issues", "create", '{"title":"Hi"}'], + }); + expect(result.path).toBe("github.issues.create"); + expect(result.args).toEqual({ title: "Hi" }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("treats a path with no trailing JSON as empty args", () => + Effect.gen(function* () { + const result = yield* resolveToolInvocation({ rawPathParts: ["github", "issues", "list"] }); + expect(result.path).toBe("github.issues.list"); + expect(result.args).toEqual({}); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails with a path-bearing message when the @file is missing", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveToolInvocation({ rawPathParts: ["x", "@/no/such/file.json"] }), + ); + expect(error.message).toContain("/no/such/file.json"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects an @file whose content is not a JSON object", () => + withTmp((dir) => + Effect.gen(function* () { + const file = join(dir, "bad.json"); + writeFileSync(file, "[1,2,3]"); + const error = yield* Effect.flip( + resolveToolInvocation({ rawPathParts: ["x", `@${file}`] }), + ); + expect(error.message).toContain("must contain a JSON object"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects a bare '@' with no path", () => + Effect.gen(function* () { + const error = yield* Effect.flip(resolveToolInvocation({ rawPathParts: ["x", "@"] })); + expect(error.message).toContain("requires a file path"); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); describe("shellQuoteArg", () => { it("quotes single quotes without breaking the shell argument", () => { diff --git a/apps/cli/src/tooling.ts b/apps/cli/src/tooling.ts index aa671981f..632054f44 100644 --- a/apps/cli/src/tooling.ts +++ b/apps/cli/src/tooling.ts @@ -1,3 +1,4 @@ +import { FileSystem } from "effect"; import * as Effect from "effect/Effect"; const isRecord = (value: unknown): value is Record => @@ -145,6 +146,65 @@ export const parseJsonObjectInput = ( return parsed; }); +export const resolveToolInvocation = (input: { + rawPathParts: ReadonlyArray; +}): Effect.Effect<{ path: string; args: Record }, Error, FileSystem.FileSystem> => + Effect.gen(function* () { + if (!Array.isArray(input.rawPathParts)) { + return yield* Effect.fail( + new Error("Invalid tool invocation: path parts were not parsed as an array"), + ); + } + + // The trailing argument carries the tool's JSON input — either inline + // (`'{"k":"v"}'`) or, via `@path`, read from a file. The file form is the + // cross-platform equivalent of the Unix `"$(cat file)"`: it dodges shell + // quote-mangling for large or double-quote-heavy payloads (notably + // PowerShell, which corrupts inline JSON passed to a native binary). + const rawLast = input.rawPathParts.at(-1)?.trim(); + const isFileArg = rawLast !== undefined && rawLast.startsWith("@"); + const filePath = isFileArg ? rawLast.slice(1) : undefined; + if (isFileArg && filePath === "") { + return yield* Effect.fail( + new Error("Tool input '@' requires a file path, e.g. `@./input.json`."), + ); + } + const jsonText = + filePath !== undefined + ? (yield* (yield* FileSystem.FileSystem).readFileString(filePath).pipe( + // Surface a path-bearing message instead of a raw ENOENT PlatformError. + Effect.mapError( + (cause) => new Error(`Cannot read tool input file '${filePath}': ${cause}`), + ), + )).trim() + : rawLast; + const hasInlineJsonArg = jsonText !== undefined && jsonText.startsWith("{"); + if (isFileArg && !hasInlineJsonArg) { + return yield* Effect.fail( + new Error(`Tool input file '${filePath}' must contain a JSON object starting with '{'.`), + ); + } + const pathParts = + isFileArg || hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts; + const args = hasInlineJsonArg ? yield* parseJsonObjectInput(jsonText) : {}; + + if (pathParts.some((part) => part.trim().startsWith("-"))) { + return yield* Effect.fail( + new Error( + "Tool invocation no longer accepts flags. Use: executor call '{...json...}'", + ), + ); + } + + const path = yield* Effect.try({ + try: () => buildToolPath(pathParts), + catch: (cause) => + cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), + }); + + return { path, args }; + }); + export const extractExecutionResult = (structured: unknown): unknown => { if (!isRecord(structured) || !("result" in structured)) { return null;