diff --git a/apps/desktop/package.json b/apps/desktop/package.json index edb859040..cd3d3b5f4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,6 +37,7 @@ "@executor-js/plugin-onepassword": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "catalog:", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0c083e990..7dfb6aaa6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -23,8 +23,18 @@ import { SidecarPortInUseError, type SidecarConnection, } from "./sidecar"; -import { getServerSettings, regeneratePassword, updateServerSettings } from "./settings"; -import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; +import { + getServerProfiles, + getServerSettings, + regeneratePassword, + setServerProfiles, + updateServerSettings, +} from "./settings"; +import { + SERVER_SETTINGS_USERNAME, + type DesktopServerConnection, + type DesktopServerSettings, +} from "../shared/server-settings"; // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every // Electron-side consumer (electron-store, electron-log, window-state) lands @@ -215,7 +225,7 @@ const startWithCurrentSettings = async (): Promise => } }; -const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: string }> => { +const restartSidecarAndReload = async (): Promise => { if (connection) { await stopSidecar(connection.child); connection = null; @@ -228,10 +238,30 @@ const restartSidecarAndReload = async (): Promise<{ port: number; baseUrl: strin connection = next; installBasicAuthHeader(next.baseUrl, next.authPassword); if (mainWindow) await mainWindow.loadURL(next.baseUrl); - return { port: next.port, baseUrl: next.baseUrl }; + return toDesktopServerConnection(next); }; +const toDesktopServerConnection = (conn: SidecarConnection): DesktopServerConnection => ({ + kind: "desktop-sidecar", + key: "desktop-sidecar", + origin: conn.baseUrl, + apiBaseUrl: `${conn.baseUrl.replace(/\/+$/, "")}/api`, + displayName: "Desktop sidecar", + ...(conn.authPassword + ? { + auth: { + kind: "basic" as const, + username: SERVER_SETTINGS_USERNAME, + password: conn.authPassword, + }, + } + : {}), +}); + const registerIpcHandlers = () => { + ipcMain.handle("executor:server:connection", (): DesktopServerConnection | null => + connection ? toDesktopServerConnection(connection) : null, + ); ipcMain.handle("executor:settings:get", (): DesktopServerSettings => getServerSettings()); ipcMain.handle( "executor:settings:update", @@ -242,6 +272,11 @@ const registerIpcHandlers = () => { "executor:settings:regenerate-password", (): DesktopServerSettings => regeneratePassword(), ); + ipcMain.handle("executor:server-profiles:get", (): string | null => getServerProfiles()); + ipcMain.handle("executor:server-profiles:set", (_evt, value: unknown): void => { + if (typeof value !== "string") return; + setServerProfiles(value); + }); ipcMain.handle("executor:server:restart", () => restartSidecarAndReload()); ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => { if (typeof rawUrl !== "string") return; diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index a89f4a562..bc5266e4c 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -4,6 +4,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/s interface PersistedShape { readonly server: DesktopServerSettings; + readonly serverProfiles?: string; } const generatePassword = (): string => randomBytes(24).toString("base64url"); @@ -43,3 +44,9 @@ export const regeneratePassword = (): DesktopServerSettings => { store.set("server", next); return next; }; + +export const getServerProfiles = (): string | null => store.get("serverProfiles") ?? null; + +export const setServerProfiles = (value: string): void => { + store.set("serverProfiles", value); +}; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index b88e8feba..f777b4d6e 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -12,10 +12,16 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve, join } from "node:path"; import { app } from "electron"; +import { Option, Schema } from "effect"; +import { + normalizeExecutorServerConnection, + parseExecutorLocalServerManifest, + serializeExecutorLocalServerManifest, +} from "@executor-js/sdk/shared"; import { getServerSettings } from "./settings"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; @@ -41,6 +47,132 @@ interface StartOptions { readonly hostname?: string; } +const sidecarManifestPathByPid = new Map(); + +const serverControlDir = (dataDir: string): string => join(dataDir, "server-control"); +const localServerManifestPath = (dataDir: string): string => + join(serverControlDir(dataDir), "server.json"); +const localServerStartLockPath = (dataDir: string): string => + join(serverControlDir(dataDir), "startup.lock"); + +const LocalServerStartLockFile = Schema.Struct({ + pid: Schema.Number, + startedAt: Schema.String, +}); +const decodeUnknownJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +const decodeLocalServerStartLockFile = Schema.decodeUnknownOption(LocalServerStartLockFile); + +const isPidAlive = (pid: number): boolean => { + if (!Number.isInteger(pid) || pid <= 0) return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Node process probing API reports liveness by throwing + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const readManifest = (dataDir: string) => { + const path = localServerManifestPath(dataDir); + if (!existsSync(path)) return null; + return parseExecutorLocalServerManifest(readFileSync(path, "utf8")); +}; + +const removeManifestIfOwnedBy = (dataDir: string, pid: number) => { + const manifest = readManifest(dataDir); + if (manifest?.pid !== pid) return; + rmSync(localServerManifestPath(dataDir), { force: true }); +}; + +const assertNoOtherLocalServerOwner = (dataDir: string) => { + const manifest = readManifest(dataDir); + if (!manifest) return; + if (!isPidAlive(manifest.pid)) { + removeManifestIfOwnedBy(dataDir, manifest.pid); + return; + } + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process + throw new Error( + [ + `A local Executor ${manifest.kind} is already running at ${manifest.connection.origin} (pid ${manifest.pid}).`, + `It owns the current data directory: ${manifest.dataDir}`, + "Stop it before starting the desktop sidecar.", + ].join("\n"), + ); +}; + +const readLockPid = (dataDir: string): number | null => { + const path = localServerStartLockPath(dataDir); + if (!existsSync(path)) return null; + const json = decodeUnknownJsonOption(readFileSync(path, "utf8")); + if (Option.isNone(json)) return null; + const decoded = decodeLocalServerStartLockFile(json.value); + return Option.isSome(decoded) ? decoded.value.pid : null; +}; + +const acquireLocalServerStartLock = (dataDir: string): (() => void) => { + mkdirSync(serverControlDir(dataDir), { recursive: true }); + const lockPath = localServerStartLockPath(dataDir); + const payload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: lock acquisition uses atomic Node fs flags and maps contention to startup failure + try { + writeFileSync(lockPath, payload, { flag: "wx" }); + } catch { + const existingPid = readLockPid(dataDir); + if (existingPid !== null && !isPidAlive(existingPid)) { + rmSync(lockPath, { force: true }); + writeFileSync(lockPath, payload, { flag: "wx" }); + } else { + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: startup failure is surfaced in the Electron main process + throw new Error("Another local Executor server startup is already in progress."); + } + } + return () => rmSync(lockPath, { force: true }); +}; + +const writeSidecarManifest = (input: { + readonly dataDir: string; + readonly scopeDir: string; + readonly baseUrl: string; + readonly authPassword: string | null; + readonly childPid: number; +}) => { + const connection = normalizeExecutorServerConnection({ + kind: "desktop-sidecar", + key: "desktop-sidecar", + origin: input.baseUrl, + displayName: "Desktop sidecar", + ...(input.authPassword + ? { + auth: { + kind: "basic" as const, + username: SERVER_SETTINGS_USERNAME, + password: input.authPassword, + }, + } + : {}), + }); + writeFileSync( + localServerManifestPath(input.dataDir), + serializeExecutorLocalServerManifest({ + version: 1, + kind: "desktop-sidecar", + pid: input.childPid, + startedAt: new Date().toISOString(), + dataDir: input.dataDir, + scopeDir: input.scopeDir, + connection, + owner: { + client: "desktop", + version: app.getVersion() || null, + executablePath: process.execPath || null, + }, + }), + ); + sidecarManifestPathByPid.set(input.childPid, input.dataDir); +}; + const resolveSidecarCommand = (): { command: string; args: string[]; cwd: string } => { if (app.isPackaged) { const binaryName = process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar"; @@ -69,6 +201,7 @@ export async function startSidecar(options: StartOptions = {}): Promise { + if (startupLockReleased) return; + startupLockReleased = true; + releaseStartupLock(); + }; - const child = spawn(command, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - EXECUTOR_PORT: String(settings.port), - EXECUTOR_HOST: hostname, - // Only export the password env var when auth is enabled — the sidecar - // treats an empty password as "no auth required". Matches the CLI's - // `executor web` default. - ...(effectivePassword ? { EXECUTOR_AUTH_PASSWORD: effectivePassword } : {}), - EXECUTOR_CLIENT_DIR: clientDir, - EXECUTOR_SCOPE_DIR: scopeDir, - EXECUTOR_DATA_DIR: scopeDir, - EXECUTOR_CLIENT: "desktop", - }, - }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup lock must be released before rethrowing Electron startup failures + try { + assertNoOtherLocalServerOwner(dataDir); + } catch (error) { + releaseLock(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve Electron startup failure after releasing local startup lock + throw error; + } + + let child: ChildProcess; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: spawn can throw synchronously and the local startup lock must be released + try { + child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + EXECUTOR_PORT: String(settings.port), + EXECUTOR_HOST: hostname, + // Only export the password env var when auth is enabled — the sidecar + // treats an empty password as "no auth required". Matches the CLI's + // `executor web` default. + ...(effectivePassword ? { EXECUTOR_AUTH_PASSWORD: effectivePassword } : {}), + EXECUTOR_CLIENT_DIR: clientDir, + EXECUTOR_SCOPE_DIR: scopeDir, + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_CLIENT: "desktop", + }, + }); + } catch (error) { + releaseLock(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve spawn failure after releasing local startup lock + throw error; + } return new Promise((resolveStart, rejectStart) => { let stderrBuffer = ""; @@ -113,6 +271,7 @@ export async function startSidecar(options: StartOptions = {}): Promise { if (resolved || rejected) return; rejected = true; + releaseLock(); // oxlint-disable-next-line executor/no-promise-reject -- boundary: sidecar startup surfaces as a rejected promise rejectStart(err); }; @@ -122,10 +281,26 @@ export async function startSidecar(options: StartOptions = {}): Promise { - if (child.exitCode !== null || child.killed) return; + const cleanupManifest = () => { + if (!child.pid) return; + const dataDir = sidecarManifestPathByPid.get(child.pid); + if (!dataDir) return; + removeManifestIfOwnedBy(dataDir, child.pid); + sidecarManifestPathByPid.delete(child.pid); + }; + if (child.exitCode !== null || child.killed) { + cleanupManifest(); + return; + } return new Promise((resolveStop) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); + cleanupManifest(); resolveStop(); }, 5000); child.once("exit", () => { clearTimeout(timeout); + cleanupManifest(); resolveStop(); }); child.kill("SIGTERM"); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 913bcd540..d729039ec 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,7 +1,19 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { DesktopServerSettings } from "../shared/server-settings"; +import type { DesktopServerConnection, DesktopServerSettings } from "../shared/server-settings"; const api = { + /** Read the active Executor server connection backing this desktop window. */ + getServerConnection(): Promise { + return ipcRenderer.invoke("executor:server:connection"); + }, + /** Read the desktop-persisted server profile payload. */ + getServerProfiles(): Promise { + return ipcRenderer.invoke("executor:server-profiles:get"); + }, + /** Persist the server profile payload in desktop storage. */ + setServerProfiles(value: string): Promise { + return ipcRenderer.invoke("executor:server-profiles:set", value); + }, /** Read the persisted server settings (port, requireAuth, password). */ getSettings(): Promise { return ipcRenderer.invoke("executor:settings:get"); @@ -16,10 +28,9 @@ const api = { }, /** * Stop + restart the sidecar so settings changes take effect. - * Renderer should reload its location after this resolves to point at - * the (possibly new) port. + * Main reloads the window and returns the refreshed server connection. */ - restartServer(): Promise<{ readonly port: number; readonly baseUrl: string }> { + restartServer(): Promise { return ipcRenderer.invoke("executor:server:restart"); }, /** diff --git a/apps/desktop/src/shared/server-settings.ts b/apps/desktop/src/shared/server-settings.ts index 79c6dbbf4..6c030f3b6 100644 --- a/apps/desktop/src/shared/server-settings.ts +++ b/apps/desktop/src/shared/server-settings.ts @@ -6,6 +6,8 @@ * renderer (Settings UI + Connect-an-agent surface) need to agree on it. */ +import type { ExecutorServerConnection } from "@executor-js/sdk/shared"; + export interface DesktopServerSettings { /** TCP port the sidecar listens on. Default 4789. */ readonly port: number; @@ -22,6 +24,16 @@ export interface DesktopServerSettings { readonly password: string; } +export type DesktopServerConnection = ExecutorServerConnection & { + readonly kind: "desktop-sidecar"; + readonly key: "desktop-sidecar"; + readonly auth?: { + readonly kind: "basic"; + readonly username: string; + readonly password: string; + }; +}; + export const DEFAULT_SERVER_SETTINGS: DesktopServerSettings = { port: 4789, requireAuth: true, diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts index 795602abb..3eb31db1e 100644 --- a/apps/desktop/src/sidecar/server.ts +++ b/apps/desktop/src/sidecar/server.ts @@ -43,15 +43,10 @@ const hostname = process.env.EXECUTOR_HOST ?? "127.0.0.1"; const authPassword = process.env.EXECUTOR_AUTH_PASSWORD; const clientDir = process.env.EXECUTOR_CLIENT_DIR; -if (!authPassword) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: misconfiguration at sidecar boot is fatal - throw new Error("EXECUTOR_AUTH_PASSWORD must be set when running the desktop sidecar."); -} - const server = await startServer({ port: requestedPort, hostname, - authPassword, + ...(authPassword ? { authPassword } : {}), clientDir, }); diff --git a/packages/plugins/desktop-settings/src/client.tsx b/packages/plugins/desktop-settings/src/client.tsx index d0ae2cc57..30ff386d3 100644 --- a/packages/plugins/desktop-settings/src/client.tsx +++ b/packages/plugins/desktop-settings/src/client.tsx @@ -3,8 +3,9 @@ * @executor-js/plugin-desktop-settings/client * * A single page mounted at `/plugins/desktop-settings/` that lets the user - * configure the Electron sidecar's port, auth, and password. Talks to the - * main process via `window.executor.*` (exposed by `apps/desktop/src/preload`). + * inspect and configure the Electron sidecar's server connection. Talks to + * the main process via `window.executor.*` (exposed by + * `apps/desktop/src/preload`). * * The plugin is bundled into apps/local's renderer too (because executor * web + desktop share the same client bundle pipeline), but the page @@ -29,19 +30,39 @@ interface DesktopServerSettings { readonly password: string; } +interface DesktopServerConnection { + readonly kind: "desktop-sidecar"; + readonly key: "desktop-sidecar"; + readonly origin: string; + readonly apiBaseUrl: string; + readonly displayName: string; + readonly auth?: { + readonly kind: "basic"; + readonly username: string; + readonly password: string; + }; +} + interface ExecutorBridge { + readonly getServerConnection: () => Promise; readonly getSettings: () => Promise; readonly updateSettings: ( patch: Partial, ) => Promise; readonly regeneratePassword: () => Promise; - readonly restartServer: () => Promise<{ readonly port: number; readonly baseUrl: string }>; + readonly restartServer: () => Promise; } const readBridge = (): ExecutorBridge | null => { if (typeof window === "undefined") return null; const candidate = (window as Window & { readonly executor?: ExecutorBridge }).executor; - if (!candidate || typeof candidate.getSettings !== "function") return null; + if ( + !candidate || + typeof candidate.getSettings !== "function" || + typeof candidate.getServerConnection !== "function" + ) { + return null; + } return candidate; }; @@ -60,6 +81,7 @@ const describeIpcError = (_err: unknown): string => function SettingsPage() { const bridge = readBridge(); + const [connection, setConnection] = useState(null); const [settings, setSettings] = useState(null); const [draft, setDraft] = useState(null); const [status, setStatus] = useState<"idle" | "saving" | "restarting" | "error">("idle"); @@ -67,10 +89,27 @@ function SettingsPage() { useEffect(() => { if (!bridge) return; - void bridge.getSettings().then((s) => { - setSettings(s); - setDraft(s); - }); + void Promise.all([bridge.getSettings(), bridge.getServerConnection()]).then( + ([nextSettings, nextConnection]) => { + setSettings(nextSettings); + setDraft(nextSettings); + setConnection(nextConnection); + }, + ); + }, [bridge]); + + const restartAndRefreshConnection = useCallback(async () => { + if (!bridge) return; + setStatus("restarting"); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC, errors surface in the form + try { + setConnection(await bridge.restartServer()); + } catch (err) { + setError(describeIpcError(err)); + setStatus("error"); + return; + } + setStatus("idle"); }, [bridge]); const apply = useCallback( @@ -89,19 +128,9 @@ function SettingsPage() { } setSettings(next); setDraft(next); - setStatus("restarting"); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: same as above - try { - await bridge.restartServer(); - } catch (err) { - setError(describeIpcError(err)); - setStatus("error"); - return; - } - // Window reload happens on the main side after restart resolves. - setStatus("idle"); + await restartAndRefreshConnection(); }, - [bridge], + [bridge, restartAndRefreshConnection], ); const regenerate = useCallback(async () => { @@ -118,31 +147,21 @@ function SettingsPage() { } setSettings(next); setDraft(next); - setStatus("restarting"); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC - try { - await bridge.restartServer(); - } catch (err) { - setError(describeIpcError(err)); - setStatus("error"); - return; - } - setStatus("idle"); - }, [bridge]); + await restartAndRefreshConnection(); + }, [bridge, restartAndRefreshConnection]); if (!bridge) { return (

Desktop server settings

- This panel configures the Executor Desktop app's local server. Open this page from the - desktop app to change the port, auth, or password. + Open this page from Executor Desktop to inspect and change the active server connection.

); } - if (!settings || !draft) { + if (!settings || !draft || !connection) { return
Loading…
; } @@ -151,10 +170,20 @@ function SettingsPage() { draft.requireAuth !== settings.requireAuth || draft.password !== settings.password; + const authLabel = + connection.auth?.kind === "basic" + ? `Basic auth as ${connection.auth.username}` + : "No HTTP auth"; + const cliProfileCommand = `executor server add desktop ${connection.origin} --default`; + const cliUseCommand = + connection.auth?.kind === "basic" + ? `EXECUTOR_AUTH_PASSWORD=${connection.auth.password} executor tools sources --server desktop` + : "executor tools sources --server desktop"; + return ( -
+

- Desktop server + Desktop server connection

- Configure how the local HTTP server in the desktop app accepts connections. Changes restart - the server immediately. + {connection.displayName}

+
+ + + + +
+ +
+
CLI profile
+ + {cliProfileCommand} + + + {cliUseCommand} + +
+
@@ -203,8 +266,7 @@ function SettingsPage() { Use without a password Disables HTTP Basic auth on the sidecar. Any process running as you on this machine - can hit /api directly. The host allowlist still blocks browser-based - attacks. Recommended only on personal devices. + can hit /api directly. @@ -246,9 +308,8 @@ function SettingsPage() {
- The renderer sends this as Authorization: Basic. AI clients using the - HTTP MCP integration need this value too — regenerating invalidates existing client - configs. + Regenerating this changes the active server connection and invalidates existing HTTP + MCP client configs.
)} @@ -290,6 +351,29 @@ function SettingsPage() { ); } +function ConnectionField(props: { readonly label: string; readonly value: string }) { + return ( +
+ + {props.label} + + + {props.value} + +
+ ); +} + // --------------------------------------------------------------------------- // Plugin spec // --------------------------------------------------------------------------- diff --git a/packages/plugins/desktop-settings/src/server.test.ts b/packages/plugins/desktop-settings/src/server.test.ts index d62c66e2d..47b3e6a10 100644 --- a/packages/plugins/desktop-settings/src/server.test.ts +++ b/packages/plugins/desktop-settings/src/server.test.ts @@ -18,7 +18,7 @@ describe("desktopSettingsPlugin", () => { expect(result).toEqual({ url: "http://executor.test/base/plugins/desktop-settings/", - flow: "Open this URL in Executor Desktop. The user can change port/auth or regenerate the password there; then rerun discovery/list tools to observe the restarted server.", + flow: "Open this URL in Executor Desktop. The user can inspect the active server connection, change port/auth, or regenerate the password there; then rerun discovery/list tools to observe the refreshed connection.", }); yield* executor.close(); diff --git a/packages/plugins/desktop-settings/src/server.ts b/packages/plugins/desktop-settings/src/server.ts index 331eb094e..68d8e2856 100644 --- a/packages/plugins/desktop-settings/src/server.ts +++ b/packages/plugins/desktop-settings/src/server.ts @@ -1,12 +1,12 @@ /** * @executor-js/plugin-desktop-settings/server * - * Zero-server-state plugin. The Desktop Settings panel reads and writes - * its configuration via Electron IPC (`window.executor.*`), not through - * the executor server. The server contribution exposes an agent-facing - * browser handoff tool so a chat flow can still route the user to the - * Desktop-only settings UI without moving the Basic-auth password - * through the model context. + * Zero-server-state plugin. The Desktop Settings panel reads the active + * Desktop sidecar Executor Server Connection and writes connection settings + * via Electron IPC (`window.executor.*`), not through the executor server. + * The server contribution exposes an agent-facing browser handoff tool so a + * chat flow can still route the user to the Desktop-only settings UI without + * moving the Basic-auth password through the model context. */ import { Effect, Schema } from "effect"; @@ -46,12 +46,12 @@ export const desktopSettingsPlugin = definePlugin((options: DesktopSettingsPlugi tool({ name: "openSettings", description: - "Return the Desktop Settings browser URL for configuring the local sidecar port, Basic-auth requirement, and generated password. This flow must stay in the Desktop UI because password display/regeneration and server restart are Electron IPC operations; never ask the user to paste the password in chat.", + "Return the Desktop Settings browser URL for inspecting and configuring the desktop-sidecar Executor Server Connection. This flow must stay in the Desktop UI because password display/regeneration and server restart are Electron IPC operations; never ask the user to paste the password in chat.", outputSchema: DesktopSettingsOpenOutputStd, execute: () => Effect.succeed({ url: `${resolveWebBaseUrl(options.webBaseUrl)}/plugins/desktop-settings/`, - flow: "Open this URL in Executor Desktop. The user can change port/auth or regenerate the password there; then rerun discovery/list tools to observe the restarted server.", + flow: "Open this URL in Executor Desktop. The user can inspect the active server connection, change port/auth, or regenerate the password there; then rerun discovery/list tools to observe the refreshed connection.", }), }), ], diff --git a/packages/react/src/api/server-profiles.test.ts b/packages/react/src/api/server-profiles.test.ts new file mode 100644 index 000000000..415eb3577 --- /dev/null +++ b/packages/react/src/api/server-profiles.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + getActiveExecutorServerProfile, + parseExecutorServerProfilesSnapshot, + readExecutorServerProfiles, + removeExecutorServerProfile, + selectExecutorServerProfile, + serializeExecutorServerProfilesSnapshot, + upsertExecutorServerProfile, + writeExecutorServerProfiles, + type ExecutorServerProfileStorage, +} from "./server-profiles"; + +const makeStorage = (): ExecutorServerProfileStorage & { readonly values: Map } => { + const values = new Map(); + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; +}; + +describe("Executor server profiles", () => { + it("reads and normalizes persisted server profiles", () => { + const storage = makeStorage(); + storage.setItem( + "profiles", + JSON.stringify({ + version: 1, + activeKey: "http:http://localhost:4788", + profiles: [ + { origin: "localhost:4788", displayName: "Local" }, + { origin: "not a url" }, + { origin: "https://executor.example", displayName: "Hosted" }, + ], + }), + ); + + const snapshot = readExecutorServerProfiles(storage, "profiles"); + + expect(snapshot.activeKey).toBe("http:http://localhost:4788"); + expect(snapshot.profiles.map((profile) => profile.origin)).toEqual([ + "http://localhost:4788", + "https://executor.example", + ]); + }); + + it("drops malformed profile storage", () => { + const storage = makeStorage(); + storage.setItem("profiles", "{"); + + expect(readExecutorServerProfiles(storage, "profiles")).toEqual({ + activeKey: null, + profiles: [], + }); + }); + + it("upserts, selects, removes, and persists profiles", () => { + const storage = makeStorage(); + const first = upsertExecutorServerProfile( + { activeKey: null, profiles: [] }, + { origin: "http://127.0.0.1:4788", displayName: "Local" }, + ); + expect(first?.activeKey).toBe("http:http://127.0.0.1:4788"); + + const second = upsertExecutorServerProfile(first!, { + origin: "https://executor.example", + displayName: "Hosted", + auth: { kind: "bearer", token: "token_123" }, + }); + expect(getActiveExecutorServerProfile(second!)?.displayName).toBe("Hosted"); + + const selected = selectExecutorServerProfile(second!, "http:http://127.0.0.1:4788"); + expect(getActiveExecutorServerProfile(selected)?.displayName).toBe("Local"); + + writeExecutorServerProfiles(storage, selected, "profiles"); + expect(storage.values.get("profiles")).toContain("token_123"); + + const roundTripped = readExecutorServerProfiles(storage, "profiles"); + expect(roundTripped.profiles).toHaveLength(2); + expect(roundTripped.profiles[1]?.auth).toEqual({ kind: "bearer", token: "token_123" }); + + const serialized = serializeExecutorServerProfilesSnapshot(roundTripped); + expect(parseExecutorServerProfilesSnapshot(serialized).profiles[1]?.auth).toEqual({ + kind: "bearer", + token: "token_123", + }); + + const removed = removeExecutorServerProfile(roundTripped, "http:http://127.0.0.1:4788"); + expect(removed.activeKey).toBe("http:https://executor.example"); + }); +}); diff --git a/packages/react/src/api/server-profiles.tsx b/packages/react/src/api/server-profiles.tsx new file mode 100644 index 000000000..3e40bdd6f --- /dev/null +++ b/packages/react/src/api/server-profiles.tsx @@ -0,0 +1,189 @@ +import { Option, Schema } from "effect"; +import { + normalizeExecutorServerConnection, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, +} from "./server-connection"; + +export const EXECUTOR_SERVER_PROFILES_STORAGE_KEY = "executor.serverConnections.v1"; + +export interface ExecutorServerProfilesSnapshot { + readonly activeKey: string | null; + readonly profiles: readonly ExecutorServerConnection[]; +} + +export interface ExecutorServerProfileStorage { + readonly getItem: (key: string) => string | null; + readonly setItem: (key: string, value: string) => void; +} + +const PersistedBasicAuth = Schema.Struct({ + kind: Schema.Literal("basic"), + username: Schema.optional(Schema.String), + password: Schema.String, +}); + +const PersistedBearerAuth = Schema.Struct({ + kind: Schema.Literal("bearer"), + token: Schema.String, +}); + +const PersistedAuth = Schema.Union([PersistedBasicAuth, PersistedBearerAuth]); + +const PersistedConnection = Schema.Struct({ + kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), + key: Schema.optional(Schema.String), + origin: Schema.optional(Schema.String), + apiBaseUrl: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + auth: Schema.optional(PersistedAuth), +}); + +const PersistedProfiles = Schema.Struct({ + version: Schema.Literal(1), + activeKey: Schema.optional(Schema.NullOr(Schema.String)), + profiles: Schema.Array(PersistedConnection), +}); + +const decodeProfilesJson = Schema.decodeUnknownOption(Schema.fromJsonString(PersistedProfiles)); + +const EMPTY_PROFILES: ExecutorServerProfilesSnapshot = { + activeKey: null, + profiles: [], +}; + +const hasHttpScheme = (value: string): boolean => /^https?:\/\//.test(value.trim()); + +const canParseOrigin = (origin: string | undefined): boolean => { + if (origin === undefined) return true; + const trimmed = origin.trim(); + if (!trimmed) return true; + return URL.canParse(hasHttpScheme(trimmed) ? trimmed : `http://${trimmed}`); +}; + +const canParseApiBaseUrl = (apiBaseUrl: string | undefined): boolean => { + if (apiBaseUrl === undefined) return true; + const trimmed = apiBaseUrl.trim(); + if (!trimmed) return true; + return hasHttpScheme(trimmed) && URL.canParse(trimmed); +}; + +const normalizeConnectionOption = ( + input: ExecutorServerConnectionInput, +): ExecutorServerConnection | null => { + if (!canParseOrigin(input.origin) || !canParseApiBaseUrl(input.apiBaseUrl)) return null; + return normalizeExecutorServerConnection(input); +}; + +const asConnectionInput = ( + connection: ExecutorServerConnection, +): ExecutorServerConnectionInput => ({ + kind: connection.kind, + key: connection.key, + origin: connection.origin, + apiBaseUrl: connection.apiBaseUrl, + displayName: connection.displayName, + ...(connection.auth ? { auth: connection.auth } : {}), +}); + +export const parseExecutorServerProfilesSnapshot = ( + raw: string | null | undefined, +): ExecutorServerProfilesSnapshot => { + if (!raw) return EMPTY_PROFILES; + const decoded = decodeProfilesJson(raw); + if (Option.isNone(decoded)) return EMPTY_PROFILES; + return normalizeExecutorServerProfilesSnapshot(decoded.value); +}; + +export const serializeExecutorServerProfilesSnapshot = ( + snapshot: ExecutorServerProfilesSnapshot, +): string => { + // Custom server auth is intentionally persisted with the profile so local-dev + // and advanced remote endpoints do not force reauth on every reload. Desktop + // stores this payload through its Electron store; web falls back to + // localStorage for the same format. + const persisted = { + version: 1, + activeKey: snapshot.activeKey, + profiles: snapshot.profiles.map(asConnectionInput), + }; + return JSON.stringify(persisted); +}; + +export const normalizeExecutorServerProfilesSnapshot = (input: { + readonly activeKey?: string | null; + readonly profiles?: readonly ExecutorServerConnectionInput[]; +}): ExecutorServerProfilesSnapshot => { + const deduped = new Map(); + for (const profile of input.profiles ?? []) { + const normalized = normalizeConnectionOption(profile); + if (!normalized) continue; + deduped.set(normalized.key, normalized); + } + + const profiles = [...deduped.values()]; + const activeKey = + input.activeKey && deduped.has(input.activeKey) ? input.activeKey : (profiles[0]?.key ?? null); + + return { activeKey, profiles }; +}; + +export const readExecutorServerProfiles = ( + storage: ExecutorServerProfileStorage | null | undefined, + storageKey = EXECUTOR_SERVER_PROFILES_STORAGE_KEY, +): ExecutorServerProfilesSnapshot => { + if (!storage) return EMPTY_PROFILES; + return parseExecutorServerProfilesSnapshot(storage.getItem(storageKey)); +}; + +export const writeExecutorServerProfiles = ( + storage: ExecutorServerProfileStorage | null | undefined, + snapshot: ExecutorServerProfilesSnapshot, + storageKey = EXECUTOR_SERVER_PROFILES_STORAGE_KEY, +): void => { + if (!storage) return; + storage.setItem(storageKey, serializeExecutorServerProfilesSnapshot(snapshot)); +}; + +export const getActiveExecutorServerProfile = ( + snapshot: ExecutorServerProfilesSnapshot, +): ExecutorServerConnection | null => + snapshot.profiles.find((profile) => profile.key === snapshot.activeKey) ?? null; + +export const upsertExecutorServerProfile = ( + snapshot: ExecutorServerProfilesSnapshot, + input: ExecutorServerConnectionInput, + options: { + readonly makeActive?: boolean; + } = {}, +): ExecutorServerProfilesSnapshot | null => { + const normalized = normalizeConnectionOption(input); + if (!normalized) return null; + const profiles = new Map(snapshot.profiles.map((profile) => [profile.key, profile])); + profiles.set(normalized.key, normalized); + const activeKey = + options.makeActive === false ? (snapshot.activeKey ?? normalized.key) : normalized.key; + return normalizeExecutorServerProfilesSnapshot({ + activeKey, + profiles: [...profiles.values()], + }); +}; + +export const selectExecutorServerProfile = ( + snapshot: ExecutorServerProfilesSnapshot, + key: string, +): ExecutorServerProfilesSnapshot => { + if (!snapshot.profiles.some((profile) => profile.key === key)) return snapshot; + return { ...snapshot, activeKey: key }; +}; + +export const removeExecutorServerProfile = ( + snapshot: ExecutorServerProfilesSnapshot, + key: string, +): ExecutorServerProfilesSnapshot => { + const profiles = snapshot.profiles.filter((profile) => profile.key !== key); + return normalizeExecutorServerProfilesSnapshot({ + activeKey: snapshot.activeKey === key ? null : snapshot.activeKey, + profiles, + }); +};