From 2646a85c5bb26ebdf9a4c6998004868133893c36 Mon Sep 17 00:00:00 2001 From: bheemreddy-samsara Date: Tue, 12 May 2026 22:02:38 -0500 Subject: [PATCH] fix(cli): support authenticated local runtimes --- .changeset/cli-authenticated-base-url.md | 6 + README.md | 10 ++ apps/cli/src/daemon.ts | 36 +++++ apps/cli/src/main.ts | 171 +++++++++++++++++----- apps/desktop/src/main/index.ts | 18 ++- apps/desktop/src/main/server-discovery.ts | 158 ++++++++++++++++++++ apps/desktop/src/main/settings.ts | 37 +++-- apps/desktop/src/main/sidecar.ts | 34 +++-- tests/daemon-bootstrap.test.ts | 24 +++ tests/desktop-server-discovery.test.ts | 146 ++++++++++++++++++ 10 files changed, 576 insertions(+), 64 deletions(-) create mode 100644 .changeset/cli-authenticated-base-url.md create mode 100644 apps/desktop/src/main/server-discovery.ts create mode 100644 tests/desktop-server-discovery.test.ts diff --git a/.changeset/cli-authenticated-base-url.md b/.changeset/cli-authenticated-base-url.md new file mode 100644 index 000000000..233816d28 --- /dev/null +++ b/.changeset/cli-authenticated-base-url.md @@ -0,0 +1,6 @@ +--- +"executor": patch +"@executor-js/desktop": patch +--- + +Allow CLI commands to target authenticated local runtimes with `--base-url` or `EXECUTOR_BASE_URL`, report local 401 responses instead of starting a duplicate daemon, and let Desktop attach to an existing unauthenticated CLI daemon using the shared `~/.executor-global` scope. diff --git a/README.md b/README.md index be6ffd7e7..6dbd10722 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,16 @@ executor call gmail send '{"to":"alice@example.com","subject":"Hi"}' `executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed. If the default port is busy, the CLI will pick an available local port and track it automatically. +To point CLI commands at an existing authenticated local runtime, pass `--base-url` or set `EXECUTOR_BASE_URL`: + +```bash +EXECUTOR_BASE_URL='http://executor:@127.0.0.1:4789' executor tools sources +``` + +When using Executor Desktop, copy the password from **Settings → Desktop server**. +If a local runtime responds with `401 Unauthorized`, the CLI reports the auth requirement instead of starting a duplicate daemon on another port. +Executor Desktop also checks for an existing unauthenticated CLI daemon on `4788` using the shared `~/.executor-global` scope before it starts its own sidecar. + If an execution pauses for auth or approval, resume it: ```bash diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 666f869e6..24cebc2e0 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; import { createServer } from "node:net"; import * as Clock from "effect/Clock"; @@ -44,6 +45,41 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed }; }; +export const baseUrlWithoutCredentials = (baseUrl: string): string => { + const parsed = new URL(baseUrl); + parsed.username = ""; + parsed.password = ""; + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +}; + +export const baseUrlAuthorizationHeader = (baseUrl: string): string | undefined => { + const parsed = new URL(baseUrl); + if (!parsed.username && !parsed.password) return undefined; + + const username = decodeURIComponent(parsed.username); + const password = decodeURIComponent(parsed.password); + const credentials = Buffer.from(`${username}:${password}`).toString("base64"); + return `Basic ${credentials}`; +}; + +export const daemonBaseUrlFromRequest = (input: { + readonly requestedBaseUrl: string; + readonly hostname: string; + readonly port: number; +}): string => { + const parsed = new URL(input.requestedBaseUrl); + parsed.protocol = "http:"; + parsed.hostname = input.hostname; + parsed.port = String(input.port); + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +}; + // --------------------------------------------------------------------------- // Local-host checks // --------------------------------------------------------------------------- diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index ccfe93ca0..4685e0a87 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -52,7 +52,7 @@ import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli" import { BunRuntime, BunServices } from "@effect/platform-bun"; import { HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient } from "effect/unstable/http"; -import { FileSystem, Path as PlatformPath } from "effect"; +import { FileSystem, Layer, Path as PlatformPath } from "effect"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Cause from "effect/Cause"; @@ -62,9 +62,12 @@ import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local" import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { fetchIntegrations } from "./integrations"; import { + baseUrlAuthorizationHeader, + baseUrlWithoutCredentials, buildDaemonSpawnSpec, chooseDaemonPort, canAutoStartLocalDaemonForHost, + daemonBaseUrlFromRequest, isDevCliEntrypoint, parseDaemonBaseUrl, spawnDetached, @@ -112,7 +115,8 @@ import embeddedWebUI from "./embedded-web-ui.gen"; const { version: CLI_VERSION } = await import("../package.json"); const DEFAULT_PORT = 4788; -const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; +const DEFAULT_BASE_URL = + process.env.EXECUTOR_BASE_URL?.trim() || `http://localhost:${DEFAULT_PORT}`; const DAEMON_BOOT_TIMEOUT_MS = 15_000; const DAEMON_BOOT_POLL_MS = 150; const DAEMON_STOP_TIMEOUT_MS = 10_000; @@ -139,27 +143,60 @@ const waitForShutdownSignal = () => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const isServerReachable = (baseUrl: string): Effect.Effect => +type ServerProbeResult = "reachable" | "unauthorized" | "unreachable"; + +const withBaseUrlCredentials = (baseUrl: string, init: RequestInit = {}): RequestInit => { + const authorization = baseUrlAuthorizationHeader(baseUrl); + if (!authorization) return init; + + const headers = new Headers(init.headers); + if (!headers.has("authorization")) { + headers.set("authorization", authorization); + } + return { ...init, headers }; +}; + +const fetchWithBaseUrlCredentials = (baseUrl: string): typeof globalThis.fetch => + ((input: RequestInfo | URL, init?: RequestInit) => { + const authorization = baseUrlAuthorizationHeader(baseUrl); + if (!authorization) return fetch(input, init); + + const request = new Request(input, init); + const headers = new Headers(request.headers); + if (!headers.has("authorization")) { + headers.set("authorization", authorization); + } + return fetch(new Request(request, { headers })); + }) as typeof globalThis.fetch; + +const probeServer = (baseUrl: string): Effect.Effect => Effect.tryPromise(() => - fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) }), + fetch( + `${baseUrlWithoutCredentials(baseUrl)}/api/scope`, + withBaseUrlCredentials(baseUrl, { signal: AbortSignal.timeout(2000) }), + ), ).pipe( Effect.flatMap((res) => { - if (!res.ok) return Effect.succeed(false); + if (res.status === 401) return Effect.succeed("unauthorized" as const); + if (!res.ok) return Effect.succeed("unreachable" as const); return Effect.tryPromise(() => res.json()).pipe( Effect.map((payload) => { - if (!isRecord(payload)) return false; - return ( - typeof payload.id === "string" && + if (!isRecord(payload)) return "unreachable" as const; + return typeof payload.id === "string" && typeof payload.name === "string" && typeof payload.dir === "string" - ); + ? ("reachable" as const) + : ("unreachable" as const); }), - Effect.catchCause(() => Effect.succeed(false)), + Effect.catchCause(() => Effect.succeed("unreachable" as const)), ); }), - Effect.catchCause(() => Effect.succeed(false)), + Effect.catchCause(() => Effect.succeed("unreachable" as const)), ); +const isServerReachable = (baseUrl: string): Effect.Effect => + probeServer(baseUrl).pipe(Effect.map((result) => result === "reachable")); + const script = process.argv[1]; const isDevMode = isDevCliEntrypoint(script); const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; @@ -177,6 +214,17 @@ const parseDaemonUrl = (baseUrl: string) => const daemonBaseUrl = (hostname: string, port: number): string => `http://${canonicalDaemonHost(hostname)}:${port}`; +const requestedDaemonBaseUrl = (input: { + requestedBaseUrl: string; + hostname: string; + port: number; +}): string => + daemonBaseUrlFromRequest({ + requestedBaseUrl: input.requestedBaseUrl, + hostname: canonicalDaemonHost(input.hostname), + port: input.port, + }); + const cleanupPointer = (input: { hostname: string; scopeId: string; port: number }) => Effect.gen(function* () { yield* removeDaemonPointer({ hostname: input.hostname, scopeId: input.scopeId }).pipe( @@ -191,10 +239,16 @@ const resolveDaemonTarget = (baseUrl: string) => const host = canonicalDaemonHost(parsed.hostname); const scopeId = currentDaemonScopeId(); const pointer = yield* readDaemonPointer({ hostname: host, scopeId }); + const requestedHasCredentials = baseUrlAuthorizationHeader(baseUrl) !== undefined; - if (pointer) { - const pointerUrl = daemonBaseUrl(pointer.hostname, pointer.port); - if (isPidAlive(pointer.pid) && (yield* isServerReachable(pointerUrl))) { + if (pointer && !requestedHasCredentials && parsed.port === DEFAULT_PORT) { + const pointerUrl = requestedDaemonBaseUrl({ + requestedBaseUrl: baseUrl, + hostname: pointer.hostname, + port: pointer.port, + }); + const probe = yield* probeServer(pointerUrl); + if (isPidAlive(pointer.pid) && (probe === "reachable" || probe === "unauthorized")) { return { baseUrl: pointerUrl, hostname: pointer.hostname, @@ -203,11 +257,17 @@ const resolveDaemonTarget = (baseUrl: string) => }; } - yield* cleanupPointer({ hostname: pointer.hostname, scopeId, port: pointer.port }); + if (!isPidAlive(pointer.pid) && probe === "unreachable") { + yield* cleanupPointer({ hostname: pointer.hostname, scopeId, port: pointer.port }); + } } return { - baseUrl: daemonBaseUrl(host, parsed.port), + baseUrl: requestedDaemonBaseUrl({ + requestedBaseUrl: baseUrl, + hostname: host, + port: parsed.port, + }), hostname: host, port: parsed.port, scopeId, @@ -302,9 +362,21 @@ const ensureDaemon = ( ): Effect.Effect => Effect.gen(function* () { const resolvedTarget = yield* resolveDaemonTarget(baseUrl); - if (yield* isServerReachable(resolvedTarget.baseUrl)) { + const probe = yield* probeServer(resolvedTarget.baseUrl); + if (probe === "reachable") { return resolvedTarget.baseUrl; } + if (probe === "unauthorized") { + return yield* Effect.fail( + new Error( + [ + `Executor is reachable at ${baseUrlWithoutCredentials(resolvedTarget.baseUrl)} but requires authentication.`, + "Pass credentials with --base-url http://executor:@host:port, or set EXECUTOR_BASE_URL to that URL.", + "For Executor Desktop, copy the password from Settings → Desktop server.", + ].join("\n"), + ), + ); + } const parsed = yield* parseDaemonUrl(baseUrl); const host = canonicalDaemonHost(parsed.hostname); @@ -313,7 +385,7 @@ const ensureDaemon = ( return yield* Effect.fail( new Error( [ - `Executor daemon is not reachable at ${baseUrl}.`, + `Executor daemon is not reachable at ${baseUrlWithoutCredentials(baseUrl)}.`, "Auto-start is only supported for local hosts.", `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${host}`, ].join("\n"), @@ -338,20 +410,21 @@ const stopDaemon = ( const scopeId = target.scopeId; const record = yield* readDaemonRecord({ hostname: host, port: target.port }); const reachable = yield* isServerReachable(target.baseUrl); + const displayUrl = baseUrlWithoutCredentials(target.baseUrl); if (!record) { if (reachable) { return yield* Effect.fail( new Error( [ - `Executor is reachable at ${target.baseUrl} but no daemon record exists.`, + `Executor is reachable at ${displayUrl} but no daemon record exists.`, "It may not be managed by this CLI process.", "Stop it from the terminal/session where it was started.", ].join("\n"), ), ); } - console.log(`No daemon running at ${target.baseUrl}.`); + console.log(`No daemon running at ${displayUrl}.`); return; } @@ -362,19 +435,19 @@ const stopDaemon = ( return yield* Effect.fail( new Error( [ - `Daemon record for ${target.baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`, + `Daemon record for ${displayUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`, "Refusing to stop an unknown process without ownership metadata.", ].join("\n"), ), ); } console.log( - `No daemon running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`, + `No daemon running at ${displayUrl} (removed stale record for pid ${record.pid}).`, ); return; } - console.log(`Stopping daemon at ${target.baseUrl} (pid ${record.pid})...`); + console.log(`Stopping daemon at ${displayUrl} (pid ${record.pid})...`); yield* terminatePid(record.pid); @@ -388,7 +461,7 @@ const stopDaemon = ( return yield* Effect.fail( new Error( [ - `Daemon at ${target.baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`, + `Daemon at ${displayUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`, "Try terminating the process manually.", ].join("\n"), ), @@ -397,7 +470,7 @@ const stopDaemon = ( yield* removeDaemonRecord({ hostname: host, port: target.port }); yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore); - console.log(`Daemon stopped at ${target.baseUrl}.`); + console.log(`Daemon stopped at ${displayUrl}.`); }).pipe(Effect.mapError(toError)); type ExecuteCodeOutcome = @@ -456,7 +529,7 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut if (input.outcome.status === "paused") { console.log(input.outcome.text); if (input.outcome.executionId) { - const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} --base-url ${input.baseUrl}`; + const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} --base-url ${baseUrlWithoutCredentials(input.baseUrl)}`; if (input.outcome.interaction?.kind === "form") { const requestedSchema = input.outcome.interaction.requestedSchema; if (requestedSchema && Object.keys(requestedSchema).length > 0) { @@ -465,11 +538,17 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut const template = buildResumeContentTemplate(requestedSchema); const contentArg = shellQuoteArg(JSON.stringify(template)); console.log("\nResume commands:"); + if (baseUrlAuthorizationHeader(input.baseUrl)) { + console.log(" Keep EXECUTOR_BASE_URL set with credentials before running resume."); + } console.log(` ${commandPrefix} --action accept --content ${contentArg}`); console.log(` ${commandPrefix} --action decline`); console.log(` ${commandPrefix} --action cancel`); } else { console.log("\nResume command:"); + if (baseUrlAuthorizationHeader(input.baseUrl)) { + console.log(" Keep EXECUTOR_BASE_URL set with credentials before running resume."); + } console.log(` ${commandPrefix} --action accept`); } } @@ -488,10 +567,14 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut // Typed API client // --------------------------------------------------------------------------- -const makeApiClient = (baseUrl: string) => - HttpApiClient.make(ExecutorApi, { baseUrl: `${baseUrl}/api` }).pipe( - Effect.provide(FetchHttpClient.layer), +const makeApiClient = (baseUrl: string) => { + const clientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchWithBaseUrlCredentials(baseUrl))), ); + return HttpApiClient.make(ExecutorApi, { + baseUrl: `${baseUrlWithoutCredentials(baseUrl)}/api`, + }).pipe(Effect.provide(clientLayer)); +}; // --------------------------------------------------------------------------- // Foreground session @@ -1381,16 +1464,20 @@ const daemonStatusCommand = Command.make( const target = yield* resolveDaemonTarget(baseUrl); const host = canonicalDaemonHost(target.hostname); - const [record, reachable] = yield* Effect.all([ + const [record, probe] = yield* Effect.all([ readDaemonRecord({ hostname: host, port: target.port }), - isServerReachable(target.baseUrl), + probeServer(target.baseUrl), ]); + const reachable = probe === "reachable"; + const displayUrl = baseUrlWithoutCredentials(target.baseUrl); if (!record) { if (reachable) { - console.log(`Daemon reachable at ${target.baseUrl} (no local ownership record).`); + console.log(`Daemon reachable at ${displayUrl} (no local ownership record).`); + } else if (probe === "unauthorized") { + console.log(`Daemon reachable at ${displayUrl}, but authentication is required.`); } else { - console.log(`Daemon not running at ${target.baseUrl}.`); + console.log(`Daemon not running at ${displayUrl}.`); } return; } @@ -1402,20 +1489,26 @@ const daemonStatusCommand = Command.make( Effect.ignore, ); console.log( - `Daemon not running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`, + `Daemon not running at ${displayUrl} (removed stale record for pid ${record.pid}).`, ); return; } console.log( - `Daemon reachable at ${target.baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`, + `Daemon reachable at ${displayUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`, ); return; } - const state = reachable ? "running" : "unreachable"; - console.log(`Daemon ${state} at ${target.baseUrl} (pid ${record.pid}).`); + if (probe === "unauthorized") { + console.log( + `Daemon reachable at ${displayUrl}, but authentication is required (pid ${record.pid}).`, + ); + } else { + const state = reachable ? "running" : "unreachable"; + console.log(`Daemon ${state} at ${displayUrl} (pid ${record.pid}).`); + } if (target.baseUrl !== baseUrl) { - console.log(`Requested: ${baseUrl}`); + console.log(`Requested: ${baseUrlWithoutCredentials(baseUrl)}`); } if (record.scopeDir) { console.log(`Scope: ${record.scopeDir}`); @@ -1442,7 +1535,7 @@ const daemonRestartCommand = Command.make( applyScope(scope); yield* stopDaemon(baseUrl); const daemonUrl = yield* ensureDaemon(baseUrl); - console.log(`Daemon restarted at ${daemonUrl}.`); + console.log(`Daemon restarted at ${baseUrlWithoutCredentials(daemonUrl)}.`); }), ).pipe(Command.withDescription("Restart the local daemon")); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e9424354c..e5dbcc7af 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -30,7 +30,7 @@ import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/ // Electron-side consumer (electron-store, electron-log, window-state) lands // at a predictable spot. User-mutable executor state (executor.jsonc, // data.db) is pinned separately to ~/.executor in main/sidecar.ts — that -// path matches the CLI's default. +// path matches the CLI/agent global scope. app.setName("Executor"); app.setPath("userData", join(app.getPath("appData"), "Executor")); @@ -217,7 +217,9 @@ const startWithCurrentSettings = async (): Promise => const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: string }> => { if (connection) { - await stopSidecar(connection.child); + if (connection.managed) { + await stopSidecar(connection.child); + } connection = null; } const next = await startWithCurrentSettings(); @@ -227,7 +229,9 @@ const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: strin } connection = next; installBasicAuthHeader(next.baseUrl, next.authPassword); - if (mainWindow) await mainWindow.loadURL(next.baseUrl); + if (mainWindow && mainWindow.webContents.getURL() !== next.baseUrl) { + await mainWindow.loadURL(next.baseUrl); + } return { port: next.port, baseUrl: next.baseUrl }; }; @@ -292,7 +296,9 @@ const promptInstallUpdate = async (version: string) => { if (response.response === 0) { // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. if (connection) { - await stopSidecar(connection.child); + if (connection.managed) { + await stopSidecar(connection.child); + } connection = null; } autoUpdater.quitAndInstall(false, true); @@ -426,7 +432,9 @@ if (ensureSingleInstance()) { app.on("before-quit", async (event) => { if (!connection) return; event.preventDefault(); - await stopSidecar(connection.child); + if (connection.managed) { + await stopSidecar(connection.child); + } connection = null; app.exit(0); }); diff --git a/apps/desktop/src/main/server-discovery.ts b/apps/desktop/src/main/server-discovery.ts new file mode 100644 index 000000000..2fa1c958f --- /dev/null +++ b/apps/desktop/src/main/server-discovery.ts @@ -0,0 +1,158 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface ExistingServerCandidate { + readonly baseUrl: string; +} + +export interface ExistingServerMatch { + readonly baseUrl: string; + readonly port: number; + readonly scopeDir: string; +} + +interface ScopeResponse { + readonly id: string; + readonly name: string; + readonly dir: string; +} + +interface DaemonPointer { + readonly version: 1; + readonly hostname: string; + readonly port: number; + readonly pid: number; + readonly scopeId: string; + readonly scopeDir: string | null; +} + +const DEFAULT_DISCOVERY_TIMEOUT_MS = 500; +const LOCAL_DAEMON_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +const isScopeResponse = (value: unknown): value is ScopeResponse => + typeof value === "object" && + value !== null && + typeof (value as Record).id === "string" && + typeof (value as Record).name === "string" && + typeof (value as Record).dir === "string"; + +const isDaemonPointer = (value: unknown): value is DaemonPointer => { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + record.version === 1 && + typeof record.hostname === "string" && + typeof record.port === "number" && + typeof record.pid === "number" && + typeof record.scopeId === "string" && + (typeof record.scopeDir === "string" || record.scopeDir === null) + ); +}; + +const normalizeDir = (dir: string): string => dir.replace(/\/+$/, ""); + +const normalizeBaseUrl = (baseUrl: string): string => baseUrl.replace(/\/+$/, ""); + +const portFromBaseUrl = (baseUrl: string): number => { + const parsed = new URL(baseUrl); + const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 80); + return port; +}; + +const isPidAlive = (pid: number): boolean => { + if (!Number.isInteger(pid) || pid <= 0) return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process probing uses Node's throwing kill(pid, 0) API + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const defaultDataDir = (): string => join(homedir(), ".executor"); + +export const defaultDesktopScopeDir = (): string => join(homedir(), ".executor-global"); + +export const discoverPointerCandidates = async (input: { + readonly scopeDir: string; + readonly dataDir?: string; + readonly readDirImpl?: typeof readdir; + readonly readFileImpl?: typeof readFile; + readonly isPidAliveImpl?: (pid: number) => boolean; +}): Promise> => { + const expectedScopeDir = normalizeDir(input.scopeDir); + const dataDir = input.dataDir ?? defaultDataDir(); + const readDirImpl = input.readDirImpl ?? readdir; + const readFileImpl = input.readFileImpl ?? readFile; + const isPidAliveImpl = input.isPidAliveImpl ?? isPidAlive; + let entries: ReadonlyArray; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: discovery must tolerate missing daemon state directory + try { + entries = await readDirImpl(dataDir); + } catch { + return []; + } + + const candidates: Array = []; + for (const entry of entries) { + if (!entry.startsWith("daemon-active-localhost-") || !entry.endsWith(".json")) continue; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: discovery must tolerate stale or malformed daemon pointer files + try { + const raw = await readFileImpl(join(dataDir, entry), "utf8"); + // oxlint-disable-next-line executor/no-json-parse -- boundary: daemon pointer discovery validates unknown JSON with isDaemonPointer before use + const parsed = JSON.parse(raw) as unknown; + if (!isDaemonPointer(parsed)) continue; + if (!parsed.scopeDir || normalizeDir(parsed.scopeDir) !== expectedScopeDir) continue; + if (!LOCAL_DAEMON_HOSTS.has(parsed.hostname.toLowerCase())) continue; + if (!isPidAliveImpl(parsed.pid)) continue; + + candidates.push({ baseUrl: `http://127.0.0.1:${parsed.port}` }); + } catch { + continue; + } + } + + return candidates; +}; + +export const discoverExistingLocalServer = async (input: { + readonly scopeDir: string; + readonly candidates?: ReadonlyArray; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; +}): Promise => { + const candidates = + input.candidates ?? (await discoverPointerCandidates({ scopeDir: input.scopeDir })); + const timeoutMs = input.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS; + const fetchImpl = input.fetchImpl ?? fetch; + const expectedScopeDir = normalizeDir(input.scopeDir); + + for (const candidate of candidates) { + const baseUrl = normalizeBaseUrl(candidate.baseUrl); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: discovery must tolerate stale or unrelated local listeners + try { + const response = await fetchImpl(`${baseUrl}/api/scope`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) continue; + + const payload = await response.json(); + if (!isScopeResponse(payload)) continue; + if (normalizeDir(payload.dir) !== expectedScopeDir) continue; + + return { + baseUrl, + port: portFromBaseUrl(baseUrl), + scopeDir: payload.dir, + }; + } catch { + continue; + } + } + + return null; +}; diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index a89f4a562..491d2e62c 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -6,6 +6,8 @@ interface PersistedShape { readonly server: DesktopServerSettings; } +type SettingsStore = Store; + const generatePassword = (): string => randomBytes(24).toString("base64url"); const seedDefaults = (): DesktopServerSettings => ({ @@ -13,17 +15,30 @@ const seedDefaults = (): DesktopServerSettings => ({ password: generatePassword(), }); -const store = new Store({ - name: "settings", - defaults: { server: seedDefaults() }, -}); +let store: SettingsStore | null = null; -// Backfill if an older settings.json predates the server section. -if (!store.has("server")) { - store.set("server", seedDefaults()); -} +const getStore = (): SettingsStore => { + if (store) return store; + + // Create the store lazily so index.ts can set Electron's userData path + // before electron-store resolves settings.json. Constructing this at module + // import time can read/write settings under Electron's default app name and + // make Desktop spawn with stale server settings such as the CLI port 4788. + const next = new Store({ + name: "settings", + defaults: { server: seedDefaults() }, + }); + + // Backfill if an older settings.json predates the server section. + if (!next.has("server")) { + next.set("server", seedDefaults()); + } + + store = next; + return next; +}; -export const getServerSettings = (): DesktopServerSettings => store.get("server"); +export const getServerSettings = (): DesktopServerSettings => getStore().get("server"); export const updateServerSettings = ( patch: Partial, @@ -34,12 +49,12 @@ export const updateServerSettings = ( requireAuth: patch.requireAuth ?? current.requireAuth, password: patch.password ?? current.password, }; - store.set("server", next); + getStore().set("server", next); return next; }; export const regeneratePassword = (): DesktopServerSettings => { const next = { ...getServerSettings(), password: generatePassword() }; - store.set("server", next); + getStore().set("server", next); return next; }; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 300f8807e..0cabc208d 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -13,9 +13,9 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import { resolve, join } from "node:path"; import { app } from "electron"; +import { discoverExistingLocalServer, defaultDesktopScopeDir } from "./server-discovery"; import { getServerSettings } from "./settings"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; @@ -25,7 +25,8 @@ export interface SidecarConnection { readonly port: number; readonly username: string; readonly authPassword: string | null; - readonly child: ChildProcess; + readonly child: ChildProcess | null; + readonly managed: boolean; } export class SidecarPortInUseError extends Error { @@ -74,16 +75,29 @@ export async function startSidecar(options: StartOptions = {}): Promise { +export async function stopSidecar(child: ChildProcess | null): Promise { + if (!child) return; if (child.exitCode !== null || child.killed) return; return new Promise((resolveStop) => { const timeout = setTimeout(() => { diff --git a/tests/daemon-bootstrap.test.ts b/tests/daemon-bootstrap.test.ts index 949cb45f4..0b0a6a2cd 100644 --- a/tests/daemon-bootstrap.test.ts +++ b/tests/daemon-bootstrap.test.ts @@ -3,9 +3,12 @@ import { createServer } from "node:net"; import * as Effect from "effect/Effect"; import { + baseUrlAuthorizationHeader, + baseUrlWithoutCredentials, buildDaemonSpawnSpec, canAutoStartLocalDaemonForHost, chooseDaemonPort, + daemonBaseUrlFromRequest, isDevCliEntrypoint, parseDaemonBaseUrl, } from "../apps/cli/src/daemon"; @@ -21,6 +24,27 @@ describe("daemon bootstrap helpers", () => { expect(parsed).toEqual({ hostname: "127.0.0.1", port: 9001 }); }); + it("derives Basic auth from base url credentials", () => { + const header = baseUrlAuthorizationHeader("http://executor:p%40ss@127.0.0.1:4789"); + expect(header).toBe("Basic ZXhlY3V0b3I6cEBzcw=="); + }); + + it("strips credentials when displaying base urls", () => { + expect(baseUrlWithoutCredentials("http://executor:secret@127.0.0.1:4789/path?q=1")).toBe( + "http://127.0.0.1:4789", + ); + }); + + it("preserves credentials when retargeting a daemon base url", () => { + expect( + daemonBaseUrlFromRequest({ + requestedBaseUrl: "http://executor:secret@127.0.0.1:4789/path?q=1", + hostname: "localhost", + port: 5000, + }), + ).toBe("http://executor:secret@localhost:5000"); + }); + it("rejects non-http schemes for auto-start", () => { expect(() => parseDaemonBaseUrl("https://localhost:4788", 4788)).toThrow( "Only http:// base URLs are supported", diff --git a/tests/desktop-server-discovery.test.ts b/tests/desktop-server-discovery.test.ts new file mode 100644 index 000000000..b10b3d0dc --- /dev/null +++ b/tests/desktop-server-discovery.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@effect/vitest"; +import { join } from "node:path"; +import { + discoverExistingLocalServer, + discoverPointerCandidates, +} from "../apps/desktop/src/main/server-discovery"; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("desktop server discovery", () => { + it("builds candidates from live same-scope daemon pointers", async () => { + const dataDir = "/Users/example/.executor-global"; + const pointer = { + version: 1, + hostname: "localhost", + port: 4788, + pid: 12345, + startedAt: "2026-05-13T00:00:00.000Z", + scopeId: "scope:/Users/example/.executor-global-global", + scopeDir: "/Users/example/.executor-global", + token: "tok_123", + }; + + const candidates = await discoverPointerCandidates({ + scopeDir: "/Users/example/.executor-global", + dataDir, + readDirImpl: (async () => [ + "daemon-active-localhost-matching.json", + "daemon-localhost-4788.json", + ]) as typeof import("node:fs/promises").readdir, + readFileImpl: (async (path) => { + expect(path).toBe(join(dataDir, "daemon-active-localhost-matching.json")); + return JSON.stringify(pointer); + }) as typeof import("node:fs/promises").readFile, + isPidAliveImpl: (pid) => pid === 12345, + }); + + expect(candidates).toEqual([{ baseUrl: "http://127.0.0.1:4788" }]); + }); + + it("ignores stale, malformed, or different-scope daemon pointers", async () => { + const dataDir = "/Users/example/.executor-global"; + const pointers: Record = { + "daemon-active-localhost-dead.json": { + version: 1, + hostname: "localhost", + port: 4788, + pid: 999, + scopeId: "scope:/Users/example/.executor-global-global", + scopeDir: "/Users/example/.executor-global", + }, + "daemon-active-localhost-other-scope.json": { + version: 1, + hostname: "localhost", + port: 4789, + pid: 12345, + scopeId: "scope:/tmp/other", + scopeDir: "/tmp/other", + }, + "daemon-active-localhost-bad.json": { version: 1 }, + }; + + const candidates = await discoverPointerCandidates({ + scopeDir: "/Users/example/.executor-global", + dataDir, + readDirImpl: (async () => Object.keys(pointers)) as typeof import("node:fs/promises").readdir, + readFileImpl: (async (path) => + JSON.stringify( + pointers[String(path).split("/").at(-1) ?? ""], + )) as typeof import("node:fs/promises").readFile, + isPidAliveImpl: (pid) => pid === 12345, + }); + + expect(candidates).toEqual([]); + }); + + it("attaches to the first unauthenticated local server with the desktop scope", async () => { + const calls: Array = []; + const match = await discoverExistingLocalServer({ + scopeDir: "/Users/example/.executor-global", + candidates: [{ baseUrl: "http://127.0.0.1:4788" }, { baseUrl: "http://127.0.0.1:4789" }], + fetchImpl: (async (input) => { + calls.push(String(input)); + return jsonResponse(200, { + id: "scope:/Users/example/.executor-global-global", + name: "/Users/example/.executor-global", + dir: "/Users/example/.executor-global", + }); + }) as typeof fetch, + }); + + expect(match).toEqual({ + baseUrl: "http://127.0.0.1:4788", + port: 4788, + scopeDir: "/Users/example/.executor-global", + }); + expect(calls).toEqual(["http://127.0.0.1:4788/api/scope"]); + }); + + it("skips authenticated or different-scope local servers", async () => { + const match = await discoverExistingLocalServer({ + scopeDir: "/Users/example/.executor-global", + candidates: [ + { baseUrl: "http://127.0.0.1:4788" }, + { baseUrl: "http://127.0.0.1:4790" }, + { baseUrl: "http://127.0.0.1:4791" }, + ], + fetchImpl: (async (input) => { + const url = String(input); + if (url.includes(":4788/")) return new Response("Unauthorized", { status: 401 }); + if (url.includes(":4790/")) { + return jsonResponse(200, { + id: "scope:/tmp/other", + name: "/tmp/other", + dir: "/tmp/other", + }); + } + return jsonResponse(200, { + id: "scope:/Users/example/.executor-global-global", + name: "/Users/example/.executor-global", + dir: "/Users/example/.executor-global/", + }); + }) as typeof fetch, + }); + + expect(match).toEqual({ + baseUrl: "http://127.0.0.1:4791", + port: 4791, + scopeDir: "/Users/example/.executor-global/", + }); + }); + + it("returns null when no compatible server is found", async () => { + const match = await discoverExistingLocalServer({ + scopeDir: "/Users/example/.executor-global", + candidates: [{ baseUrl: "http://127.0.0.1:4788" }], + fetchImpl: (async () => new Response("Not Found", { status: 404 })) as typeof fetch, + }); + + expect(match).toBeNull(); + }); +});