diff --git a/packages/core/sdk/src/client.test.ts b/packages/core/sdk/src/client.test.ts index 0e8bf9b74..3a3bff9b7 100644 --- a/packages/core/sdk/src/client.test.ts +++ b/packages/core/sdk/src/client.test.ts @@ -22,9 +22,10 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; +import { HttpClientRequest } from "effect/unstable/http"; import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { createPluginAtomClient } from "./client"; +import { applyPluginAtomClientRequestTransform, createPluginAtomClient } from "./client"; const FooGroup = HttpApiGroup.make("foo").add( HttpApiEndpoint.get("ping", "/ping", { success: Schema.String }), @@ -65,4 +66,15 @@ describe("createPluginAtomClient", () => { expect(ping).toBeTruthy(); expect(set).toBeTruthy(); }); + + it("can apply dynamic server connection URL and auth to plugin requests", () => { + const request = HttpClientRequest.get("/graphql/sources"); + const transformed = applyPluginAtomClientRequestTransform(request, { + baseUrl: () => "https://executor.example/api", + authorizationHeader: () => "Bearer key_123", + }); + + expect(transformed.url).toBe("https://executor.example/api/graphql/sources"); + expect(transformed.headers.authorization).toBe("Bearer key_123"); + }); }); diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index afab76f35..29614fd3a 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -192,8 +192,32 @@ export interface CreatePluginAtomClientOptions { * when forwarding to the Effect handler) — same convention as the * core `ExecutorApiClient`. */ readonly baseUrl?: string | (() => string); + /** Optional dynamic Authorization header for hosts whose active + * Executor Server Connection requires Basic or Bearer auth. */ + readonly authorizationHeader?: string | null | (() => string | null); } +export interface PluginAtomClientRequestTransformOptions { + readonly baseUrl?: () => string; + readonly authorizationHeader?: string | null | (() => string | null); +} + +/** @internal */ +export const applyPluginAtomClientRequestTransform = ( + request: HttpClientRequest.HttpClientRequest, + options: PluginAtomClientRequestTransformOptions, +): HttpClientRequest.HttpClientRequest => { + let next = options.baseUrl ? HttpClientRequest.prependUrl(request, options.baseUrl()) : request; + const authorization = + typeof options.authorizationHeader === "function" + ? options.authorizationHeader() + : options.authorizationHeader; + if (authorization) { + next = HttpClientRequest.setHeader(next, "authorization", authorization); + } + return next; +}; + /** * Build a typed reactive client for a plugin's HttpApiGroup. * @@ -211,19 +235,33 @@ export const createPluginAtomClient = < group: G, options: CreatePluginAtomClientOptions = {}, ) => { - const { baseUrl = "/api" } = options; + const { baseUrl = "/api", authorizationHeader } = options; const pluginId = group.identifier; const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); + const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; + const staticBaseUrl = typeof baseUrl === "function" ? undefined : baseUrl; + const getAuthorizationHeader = + typeof authorizationHeader === "function" ? authorizationHeader : null; + const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; + const transformClient = + getBaseUrl || hasAuthorization + ? HttpClient.mapRequest((request) => + applyPluginAtomClientRequestTransform(request, { + ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), + ...(getAuthorizationHeader + ? { authorizationHeader: getAuthorizationHeader } + : authorizationHeader !== undefined + ? { authorizationHeader } + : {}), + }), + ) + : undefined; + return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, { api: bundle, httpClient: FetchHttpClient.layer, - ...(typeof baseUrl === "function" - ? { - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.prependUrl(request, baseUrl()), - ), - } - : { baseUrl }), + ...(staticBaseUrl !== undefined ? { baseUrl: staticBaseUrl } : {}), + ...(transformClient ? { transformClient } : {}), }); }; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 6b23064bb..007562b3e 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -256,6 +256,20 @@ export { type HostedHttpClientOptions, } from "./hosted-http-client"; +export { + DEFAULT_EXECUTOR_SERVER_ORIGIN, + DEFAULT_EXECUTOR_SERVER_USERNAME, + apiBaseUrlForServerOrigin, + getExecutorServerAuthorizationHeader, + normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, + originFromApiBaseUrl, + type ExecutorServerAuth, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, + type ExecutorServerConnectionKind, +} from "./server-connection"; + export { OAuthDiscoveryError, OAuthAuthorizationServerMetadataSchema, diff --git a/packages/core/sdk/src/server-connection.test.ts b/packages/core/sdk/src/server-connection.test.ts new file mode 100644 index 000000000..8b26049d2 --- /dev/null +++ b/packages/core/sdk/src/server-connection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + apiBaseUrlForServerOrigin, + getExecutorServerAuthorizationHeader, + normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, + originFromApiBaseUrl, + parseExecutorLocalServerManifest, + serializeExecutorLocalServerManifest, +} from "./server-connection"; + +describe("Executor server connection", () => { + it("normalizes server origins and API base URLs", () => { + expect(normalizeExecutorServerOrigin("localhost:4788/")).toBe("http://localhost:4788"); + expect(normalizeExecutorServerOrigin("http://localhost:4788/api")).toBe( + "http://localhost:4788", + ); + expect(apiBaseUrlForServerOrigin("http://localhost:4788")).toBe("http://localhost:4788/api"); + expect(originFromApiBaseUrl("http://localhost:4788/api")).toBe("http://localhost:4788"); + }); + + it("builds a stable connection from an explicit server origin", () => { + const connection = normalizeExecutorServerConnection({ + origin: "https://executor.example", + displayName: "Remote Executor", + }); + + expect(connection).toMatchObject({ + kind: "http", + key: "http:https://executor.example", + origin: "https://executor.example", + apiBaseUrl: "https://executor.example/api", + displayName: "Remote Executor", + }); + }); + + it("builds authorization headers from server auth", () => { + expect( + getExecutorServerAuthorizationHeader( + normalizeExecutorServerConnection({ + origin: "http://127.0.0.1:4789", + auth: { + kind: "basic", + username: "executor", + password: "secret", + }, + }), + ), + ).toBe("Basic ZXhlY3V0b3I6c2VjcmV0"); + + expect( + getExecutorServerAuthorizationHeader( + normalizeExecutorServerConnection({ + origin: "https://executor.example", + auth: { + kind: "bearer", + token: "remote-token", + }, + }), + ), + ).toBe("Bearer remote-token"); + }); + + it("round-trips local server owner manifests", () => { + const manifest = { + version: 1 as const, + kind: "desktop-sidecar" as const, + pid: 1234, + startedAt: "2026-05-28T00:00:00.000Z", + dataDir: "/Users/rhys/.executor", + scopeDir: "/Users/rhys/.executor", + connection: normalizeExecutorServerConnection({ + kind: "desktop-sidecar", + key: "desktop-sidecar", + origin: "http://127.0.0.1:4789", + auth: { kind: "basic", username: "executor", password: "secret" }, + }), + owner: { + client: "desktop" as const, + version: "1.2.3", + executablePath: "/Applications/Executor.app/Contents/MacOS/Executor", + }, + }; + + expect( + parseExecutorLocalServerManifest(serializeExecutorLocalServerManifest(manifest)), + ).toEqual(manifest); + expect(parseExecutorLocalServerManifest("{")).toBeNull(); + expect(parseExecutorLocalServerManifest(JSON.stringify({ ...manifest, pid: -1 }))).toBeNull(); + }); +}); diff --git a/packages/core/sdk/src/server-connection.ts b/packages/core/sdk/src/server-connection.ts new file mode 100644 index 000000000..9e7fa555a --- /dev/null +++ b/packages/core/sdk/src/server-connection.ts @@ -0,0 +1,216 @@ +import { Option, Schema } from "effect"; + +export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; +export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; + +export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; +export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground"; + +export type ExecutorServerAuth = + | { + readonly kind: "basic"; + readonly username?: string; + readonly password: string; + } + | { + readonly kind: "bearer"; + readonly token: string; + }; + +export interface ExecutorServerConnection { + readonly kind: ExecutorServerConnectionKind; + readonly key: string; + readonly origin: string; + readonly apiBaseUrl: string; + readonly displayName: string; + readonly auth?: ExecutorServerAuth; +} + +export interface ExecutorServerConnectionInput { + readonly kind?: ExecutorServerConnectionKind; + readonly key?: string; + readonly origin?: string; + readonly apiBaseUrl?: string; + readonly displayName?: string; + readonly auth?: ExecutorServerAuth; +} + +export interface ExecutorLocalServerManifest { + readonly version: 1; + readonly kind: ExecutorLocalServerKind; + readonly pid: number; + readonly startedAt: string; + readonly dataDir: string; + readonly scopeDir: string | null; + readonly connection: ExecutorServerConnection; + readonly owner: { + readonly client: "cli" | "desktop"; + readonly version: string | null; + readonly executablePath: string | null; + }; +} + +const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); + +const displayNameFromOrigin = (origin: string): string => + origin.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + +export const normalizeExecutorServerOrigin = (raw: string): string => { + const trimmed = stripTrailingSlash(raw.trim()); + if (!trimmed) return DEFAULT_EXECUTOR_SERVER_ORIGIN; + + const parsed = new URL(/^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`); + if (parsed.pathname === "/api") { + parsed.pathname = "/"; + } + parsed.search = ""; + parsed.hash = ""; + return stripTrailingSlash(parsed.toString()); +}; + +export const apiBaseUrlForServerOrigin = (origin: string): string => `${origin}/api`; + +export const originFromApiBaseUrl = (raw: string): string => { + const parsed = new URL(raw); + if (parsed.pathname.endsWith("/api")) { + parsed.pathname = parsed.pathname.slice(0, -"/api".length) || "/"; + } + parsed.search = ""; + parsed.hash = ""; + return normalizeExecutorServerOrigin(parsed.toString()); +}; + +export const normalizeExecutorServerConnection = ( + input: ExecutorServerConnectionInput = {}, +): ExecutorServerConnection => { + const origin = normalizeExecutorServerOrigin( + input.origin ?? + (input.apiBaseUrl ? originFromApiBaseUrl(input.apiBaseUrl) : DEFAULT_EXECUTOR_SERVER_ORIGIN), + ); + const apiBaseUrl = stripTrailingSlash(input.apiBaseUrl ?? apiBaseUrlForServerOrigin(origin)); + const kind = input.kind ?? "http"; + + return { + kind, + key: input.key ?? `${kind}:${origin}`, + origin, + apiBaseUrl, + displayName: input.displayName ?? displayNameFromOrigin(origin), + ...(input.auth ? { auth: input.auth } : {}), + }; +}; + +const encodeBasicCredentials = (credentials: string): string | null => { + if (typeof globalThis.btoa === "function") { + return globalThis.btoa(credentials); + } + + const buffer = ( + globalThis as { + readonly Buffer?: { + readonly from: (value: string) => { readonly toString: (encoding: "base64") => string }; + }; + } + ).Buffer; + if (buffer) { + return buffer.from(credentials).toString("base64"); + } + + return null; +}; + +export const getExecutorServerAuthorizationHeader = ( + connection: ExecutorServerConnection, +): string | null => { + const auth = connection.auth; + if (!auth) return null; + if (auth.kind === "bearer") return `Bearer ${auth.token}`; + const encoded = encodeBasicCredentials( + `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`, + ); + return encoded ? `Basic ${encoded}` : null; +}; + +const ExecutorServerAuthJson = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("basic"), + username: Schema.optional(Schema.String), + password: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("bearer"), + token: Schema.String, + }), +]); + +const ExecutorServerConnectionJson = Schema.Struct({ + kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), + key: Schema.optional(Schema.String), + origin: Schema.String, + apiBaseUrl: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + auth: Schema.optional(ExecutorServerAuthJson), +}); + +const ExecutorLocalServerManifestJson = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literals(["cli-daemon", "desktop-sidecar", "foreground"]), + pid: Schema.Number, + startedAt: Schema.String, + dataDir: Schema.String, + scopeDir: Schema.NullOr(Schema.String), + connection: ExecutorServerConnectionJson, + owner: Schema.Struct({ + client: Schema.Literals(["cli", "desktop"]), + version: Schema.NullOr(Schema.String), + executablePath: Schema.NullOr(Schema.String), + }), +}); + +const decodeUnknownJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +const decodeExecutorLocalServerManifestJson = Schema.decodeUnknownOption( + ExecutorLocalServerManifestJson, +); + +const canNormalizeServerOrigin = (origin: string): boolean => { + const trimmed = stripTrailingSlash(origin.trim()); + if (!trimmed) return true; + return URL.canParse(/^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`); +}; + +export const parseExecutorLocalServerManifest = ( + raw: string, +): ExecutorLocalServerManifest | null => { + const json = decodeUnknownJsonOption(raw); + if (Option.isNone(json)) return null; + const decoded = decodeExecutorLocalServerManifestJson(json.value); + if (Option.isNone(decoded)) return null; + const parsed = decoded.value; + if ( + !Number.isInteger(parsed.pid) || + parsed.pid <= 0 || + !canNormalizeServerOrigin(parsed.connection.origin) + ) { + return null; + } + + const connection = normalizeExecutorServerConnection(parsed.connection); + return { + version: 1, + kind: parsed.kind, + pid: parsed.pid, + startedAt: parsed.startedAt, + dataDir: parsed.dataDir, + scopeDir: parsed.scopeDir, + connection, + owner: { + client: parsed.owner.client, + version: parsed.owner.version, + executablePath: parsed.owner.executablePath, + }, + }; +}; + +export const serializeExecutorLocalServerManifest = ( + manifest: ExecutorLocalServerManifest, +): string => `${JSON.stringify(manifest, null, 2)}\n`; diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index a57f64b31..536c23e68 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -70,6 +70,24 @@ export { SourceDetectionResult, type Source } from "./types"; export { Usage } from "./usages"; +export { + DEFAULT_EXECUTOR_SERVER_ORIGIN, + DEFAULT_EXECUTOR_SERVER_USERNAME, + apiBaseUrlForServerOrigin, + getExecutorServerAuthorizationHeader, + normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, + originFromApiBaseUrl, + parseExecutorLocalServerManifest, + serializeExecutorLocalServerManifest, + type ExecutorLocalServerKind, + type ExecutorLocalServerManifest, + type ExecutorServerAuth, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, + type ExecutorServerConnectionKind, +} from "./server-connection"; + export { OAUTH_POPUP_MESSAGE_TYPE, isOAuthPopupResult, diff --git a/packages/plugins/graphql/src/react/client.ts b/packages/plugins/graphql/src/react/client.ts index ef5939e4c..2e373d56a 100644 --- a/packages/plugins/graphql/src/react/client.ts +++ b/packages/plugins/graphql/src/react/client.ts @@ -1,7 +1,11 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; -import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; import { GraphqlGroup } from "../api/group"; export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { - baseUrl: getBaseUrl, + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, }); diff --git a/packages/plugins/mcp/src/react/client.ts b/packages/plugins/mcp/src/react/client.ts index a0b5abe9d..c8467648b 100644 --- a/packages/plugins/mcp/src/react/client.ts +++ b/packages/plugins/mcp/src/react/client.ts @@ -1,7 +1,11 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; -import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; import { McpGroup } from "../api/group"; export const McpClient = createPluginAtomClient(McpGroup, { - baseUrl: getBaseUrl, + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, }); diff --git a/packages/plugins/onepassword/src/react/client.ts b/packages/plugins/onepassword/src/react/client.ts index cfcd96365..471659453 100644 --- a/packages/plugins/onepassword/src/react/client.ts +++ b/packages/plugins/onepassword/src/react/client.ts @@ -1,7 +1,11 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; -import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; import { OnePasswordGroup } from "../api/group"; export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, { - baseUrl: getBaseUrl, + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, }); diff --git a/packages/plugins/openapi/src/react/client.ts b/packages/plugins/openapi/src/react/client.ts index ce8487142..7845755ca 100644 --- a/packages/plugins/openapi/src/react/client.ts +++ b/packages/plugins/openapi/src/react/client.ts @@ -1,7 +1,11 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; -import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; import { OpenApiGroup } from "../api/group"; export const OpenApiClient = createPluginAtomClient(OpenApiGroup, { - baseUrl: getBaseUrl, + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, }); diff --git a/packages/react/src/api/base-url.tsx b/packages/react/src/api/base-url.tsx index 353bc8cb6..92a27f7f0 100644 --- a/packages/react/src/api/base-url.tsx +++ b/packages/react/src/api/base-url.tsx @@ -1,41 +1,13 @@ -interface ExecutorWindowConfig { - readonly baseUrl?: string; - readonly authPassword?: string; -} +import { + getExecutorApiBaseUrl, + getExecutorServerAuthPassword, + setExecutorServerApiBaseUrl, +} from "./server-connection"; -declare global { - interface Window { - readonly executor?: ExecutorWindowConfig; - } -} - -const DEFAULT_BASE_URL = "http://127.0.0.1:4000"; - -const resolveInitialBaseUrl = (): string => { - if (typeof window === "undefined") { - return `${DEFAULT_BASE_URL}/api`; - } - const electronBaseUrl = window.executor?.baseUrl; - if (electronBaseUrl) { - // Electron sidecar exposes the localhost server origin (no /api suffix). - // Append /api to match the on-disk routing layout. - return electronBaseUrl.replace(/\/$/, "") + "/api"; - } - if (typeof window.location?.origin === "string") { - return `${window.location.origin}/api`; - } - return `${DEFAULT_BASE_URL}/api`; -}; - -let baseUrl = resolveInitialBaseUrl(); - -export const getBaseUrl = (): string => baseUrl; +export const getBaseUrl = (): string => getExecutorApiBaseUrl(); export const setBaseUrl = (url: string): void => { - baseUrl = url; + setExecutorServerApiBaseUrl(url); }; -export const getAuthPassword = (): string | null => { - if (typeof window === "undefined") return null; - return window.executor?.authPassword ?? null; -}; +export const getAuthPassword = (): string | null => getExecutorServerAuthPassword(); diff --git a/packages/react/src/api/client.tsx b/packages/react/src/api/client.tsx index 20b74a79b..44c71c898 100644 --- a/packages/react/src/api/client.tsx +++ b/packages/react/src/api/client.tsx @@ -7,8 +7,8 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { getAuthPassword, getBaseUrl } from "./base-url"; import { reportHandledFrontendError } from "./error-reporting"; +import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; const isApiClientInfrastructureCause = (cause: Cause.Cause): boolean => Option.match(Cause.findErrorOption(cause), { @@ -29,23 +29,14 @@ const reportApiClientInfrastructureCause = (cause: Cause.Cause) => // Core API client — tools + secrets // --------------------------------------------------------------------------- -const electronBasicHeader = (): string | null => { - const password = getAuthPassword(); - if (!password) return null; - if (typeof globalThis.btoa !== "function") return null; - // The Electron sidecar uses Basic auth with the literal username "executor" - // and a session-generated password injected at preload time. - return `Basic ${globalThis.btoa(`executor:${password}`)}`; -}; - const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorApiClient", { api: ExecutorApi, httpClient: FetchHttpClient.layer, transformClient: HttpClient.mapRequest((request) => { - let next = HttpClientRequest.prependUrl(request, getBaseUrl()); - const basic = electronBasicHeader(); - if (basic) { - next = HttpClientRequest.setHeader(next, "authorization", basic); + let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl()); + const authorization = getExecutorServerAuthorizationHeader(); + if (authorization) { + next = HttpClientRequest.setHeader(next, "authorization", authorization); } return next; }), diff --git a/packages/react/src/api/provider.tsx b/packages/react/src/api/provider.tsx index dab7b8350..4905ff9b2 100644 --- a/packages/react/src/api/provider.tsx +++ b/packages/react/src/api/provider.tsx @@ -2,16 +2,44 @@ import { RegistryProvider } from "@effect/atom-react"; import * as React from "react"; import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; import { ScopeProvider } from "./scope-context"; +import { + ExecutorServerConnectionProvider, + useExecutorServerConnection, + type ExecutorServerConnectionInput, +} from "./server-connection"; + +function ExecutorRegistryProvider( + props: React.PropsWithChildren<{ + readonly fallback?: React.ReactNode; + readonly scopeFailureFallback?: React.ReactNode; + }>, +) { + const connection = useExecutorServerConnection(); + return ( + + + {props.children} + + + ); +} export const ExecutorProvider = ( props: React.PropsWithChildren<{ + connection?: ExecutorServerConnectionInput; fallback?: React.ReactNode; + scopeFailureFallback?: React.ReactNode; onHandledError?: FrontendErrorReporter; }>, ) => ( - - {props.children} - + + + {props.children} + + ); diff --git a/packages/react/src/api/scope-context.tsx b/packages/react/src/api/scope-context.tsx index 8573a1694..023275295 100644 --- a/packages/react/src/api/scope-context.tsx +++ b/packages/react/src/api/scope-context.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useAtomValue } from "@effect/atom-react"; +import { Option } from "effect"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import type { ScopeId } from "@executor-js/sdk/shared"; @@ -24,13 +25,29 @@ const ScopeContext = React.createContext(null); * Provides the server scope to all children. * Renders the optional `fallback` until the scope is fetched. */ -export function ScopeProvider(props: React.PropsWithChildren<{ fallback?: React.ReactNode }>) { +export function ScopeProvider( + props: React.PropsWithChildren<{ + fallback?: React.ReactNode; + failureFallback?: React.ReactNode; + }>, +) { const result = useAtomValue(scopeAtom); if (AsyncResult.isSuccess(result)) { return {props.children}; } + if (AsyncResult.isFailure(result)) { + if (Option.isSome(result.previousSuccess)) { + return ( + + {props.children} + + ); + } + return <>{props.failureFallback ?? props.fallback ?? null}; + } + return <>{props.fallback ?? null}; } diff --git a/packages/react/src/api/server-connection.test.ts b/packages/react/src/api/server-connection.test.ts new file mode 100644 index 000000000..c240a23a0 --- /dev/null +++ b/packages/react/src/api/server-connection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + apiBaseUrlForServerOrigin, + getExecutorServerAuthorizationHeader, + normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, + originFromApiBaseUrl, + resolveBrowserExecutorServerConnection, +} from "./server-connection"; + +describe("Executor server connection", () => { + it("normalizes server origins and API base URLs", () => { + expect(normalizeExecutorServerOrigin("localhost:4788/")).toBe("http://localhost:4788"); + expect(normalizeExecutorServerOrigin("http://localhost:4788/api")).toBe( + "http://localhost:4788", + ); + expect(apiBaseUrlForServerOrigin("http://localhost:4788")).toBe("http://localhost:4788/api"); + expect(originFromApiBaseUrl("http://localhost:4788/api")).toBe("http://localhost:4788"); + }); + + it("builds a stable connection from an explicit server origin", () => { + const connection = normalizeExecutorServerConnection({ + origin: "https://executor.example", + displayName: "Remote Executor", + }); + + expect(connection).toMatchObject({ + kind: "http", + key: "http:https://executor.example", + origin: "https://executor.example", + apiBaseUrl: "https://executor.example/api", + displayName: "Remote Executor", + }); + }); + + it("preserves desktop sidecar compatibility from the legacy window bridge", () => { + const connection = resolveBrowserExecutorServerConnection({ + locationOrigin: "https://ignored.example", + bridge: { + baseUrl: "http://127.0.0.1:4789", + authPassword: "secret", + }, + }); + + expect(connection.kind).toBe("desktop-sidecar"); + expect(connection.origin).toBe("http://127.0.0.1:4789"); + expect(connection.apiBaseUrl).toBe("http://127.0.0.1:4789/api"); + expect(getExecutorServerAuthorizationHeader(connection)).toBe("Basic ZXhlY3V0b3I6c2VjcmV0"); + }); +}); diff --git a/packages/react/src/api/server-connection.tsx b/packages/react/src/api/server-connection.tsx new file mode 100644 index 000000000..adb071269 --- /dev/null +++ b/packages/react/src/api/server-connection.tsx @@ -0,0 +1,206 @@ +import * as React from "react"; +import { + DEFAULT_EXECUTOR_SERVER_ORIGIN, + DEFAULT_EXECUTOR_SERVER_USERNAME, + getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection, + normalizeExecutorServerConnection, + originFromApiBaseUrl, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, +} from "@executor-js/sdk/shared"; + +export { + DEFAULT_EXECUTOR_SERVER_ORIGIN, + DEFAULT_EXECUTOR_SERVER_USERNAME, + apiBaseUrlForServerOrigin, + normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, + originFromApiBaseUrl, + type ExecutorServerAuth, + type ExecutorServerConnection, + type ExecutorServerConnectionInput, + type ExecutorServerConnectionKind, +} from "@executor-js/sdk/shared"; + +interface ExecutorWindowBridge { + readonly serverConnection?: ExecutorServerConnectionInput; + readonly getServerConnection?: () => Promise; + readonly getServerProfiles?: () => Promise; + readonly setServerProfiles?: (value: string) => Promise; + readonly baseUrl?: string; + readonly authPassword?: string; +} + +declare global { + interface Window { + readonly executor?: ExecutorWindowBridge; + } +} + +export const resolveBrowserExecutorServerConnection = (input: { + readonly locationOrigin?: string; + readonly bridge?: ExecutorWindowBridge; +}): ExecutorServerConnection => { + const configured = input.bridge?.serverConnection; + if (configured) { + return normalizeExecutorServerConnection(configured); + } + + const legacyBaseUrl = input.bridge?.baseUrl; + if (legacyBaseUrl) { + return normalizeExecutorServerConnection({ + kind: "desktop-sidecar", + origin: legacyBaseUrl, + displayName: "Desktop sidecar", + ...(input.bridge?.authPassword + ? { + auth: { + kind: "basic", + username: DEFAULT_EXECUTOR_SERVER_USERNAME, + password: input.bridge.authPassword, + }, + } + : {}), + }); + } + + return normalizeExecutorServerConnection({ + kind: "http", + origin: input.locationOrigin ?? DEFAULT_EXECUTOR_SERVER_ORIGIN, + }); +}; + +const resolveInitialExecutorServerConnection = (): ExecutorServerConnection => { + const browserWindow = globalThis.window; + if (!browserWindow) { + return normalizeExecutorServerConnection(); + } + + return resolveBrowserExecutorServerConnection({ + locationOrigin: browserWindow.location?.origin, + bridge: browserWindow.executor, + }); +}; + +let activeConnection = resolveInitialExecutorServerConnection(); + +export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; + +export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { + activeConnection = normalizeExecutorServerConnection(input); +}; + +export const setExecutorServerApiBaseUrl = (apiBaseUrl: string): void => { + activeConnection = normalizeExecutorServerConnection({ + ...activeConnection, + apiBaseUrl, + origin: originFromApiBaseUrl(apiBaseUrl), + }); +}; + +export const getExecutorApiBaseUrl = (): string => activeConnection.apiBaseUrl; + +export const getExecutorServerAuthPassword = (): string | null => + activeConnection.auth?.kind === "basic" ? activeConnection.auth.password : null; + +export const getExecutorServerAuthorizationHeader = ( + connection: ExecutorServerConnection = activeConnection, +): string | null => getAuthorizationHeaderForConnection(connection); + +interface ExecutorServerConnectionContextValue { + readonly connection: ExecutorServerConnection; + readonly setConnection: (input: ExecutorServerConnectionInput) => void; +} + +const ExecutorServerConnectionContext = + React.createContext(null); + +export function ExecutorServerConnectionProvider( + props: React.PropsWithChildren<{ + readonly connection?: ExecutorServerConnectionInput; + }>, +) { + const initialConnection = React.useMemo( + () => + props.connection + ? normalizeExecutorServerConnection(props.connection) + : getExecutorServerConnection(), + [props.connection], + ); + const [connection, setConnection] = React.useState(initialConnection); + const setActiveConnection = React.useCallback((input: ExecutorServerConnectionInput): void => { + const next = normalizeExecutorServerConnection(input); + activeConnection = next; + setConnection(next); + }, []); + + React.useEffect(() => { + const next = props.connection + ? normalizeExecutorServerConnection(props.connection) + : getExecutorServerConnection(); + activeConnection = next; + setConnection(next); + }, [props.connection]); + + React.useEffect(() => { + const bridge = globalThis.window?.executor; + if (props.connection || !bridge) return; + if (typeof bridge?.getServerConnection !== "function") return; + + let cancelled = false; + const initialKey = activeConnection.key; + void bridge.getServerConnection().then( + (input) => { + if (cancelled || !input) return; + const next = normalizeExecutorServerConnection(input); + setConnection((current) => { + if (current.key !== initialKey) return current; + activeConnection = next; + return next; + }); + }, + () => undefined, + ); + + return () => { + cancelled = true; + }; + }, [props.connection]); + + activeConnection = connection; + const value = React.useMemo( + () => ({ + connection, + setConnection: setActiveConnection, + }), + [connection, setActiveConnection], + ); + + return ( + + {props.children} + + ); +} + +export function useExecutorServerConnection(): ExecutorServerConnection { + return ( + React.useContext(ExecutorServerConnectionContext)?.connection ?? getExecutorServerConnection() + ); +} + +export function useSetExecutorServerConnection(): (input: ExecutorServerConnectionInput) => void { + return ( + React.useContext(ExecutorServerConnectionContext)?.setConnection ?? setExecutorServerConnection + ); +} + +export function useExecutorServerConnectionControls(): ExecutorServerConnectionContextValue { + const value = React.useContext(ExecutorServerConnectionContext); + return ( + value ?? { + connection: getExecutorServerConnection(), + setConnection: setExecutorServerConnection, + } + ); +} diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index 07ebb9996..abb7a73e6 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -30,6 +30,19 @@ describe("MCP install command rendering", () => { ).toBe("npx add-mcp http://localhost:4788/mcp --transport http --name executor"); }); + it("renders active server authorization as an HTTP MCP header", () => { + expect( + buildMcpInstallCommand({ + mode: "http", + isDev: false, + origin: "http://127.0.0.1:4789", + authorizationHeader: "Basic abc123", + }), + ).toBe( + "npx add-mcp http://127.0.0.1:4789/mcp --transport http --name executor --header 'Authorization: Basic abc123'", + ); + }); + it("uses model-managed resume by default and encodes explicit elicitation modes", () => { expect( buildMcpHttpEndpoint({ diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index 3502266db..968bda676 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import CursorIcon from "@lobehub/icons/es/Cursor/components/Mono"; import ClaudeIcon from "@lobehub/icons/es/Claude/components/Color"; import OpenCodeIcon from "@lobehub/icons/es/OpenCode/components/Mono"; @@ -10,6 +10,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsib import { NativeSelect, NativeSelectOption } from "./native-select"; import { cn } from "../lib/utils"; import { useScopeInfo } from "../api/scope-context"; +import { + getExecutorServerAuthorizationHeader, + useExecutorServerConnection, +} from "../api/server-connection"; type TransportMode = "stdio" | "http"; export type McpElicitationMode = "browser" | "model" | "native"; @@ -22,35 +26,24 @@ const SUPPORTED_AGENTS = [ const isDev = import.meta.env.DEV; const devCliCwd = import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD as string | undefined; +const currentLocation = globalThis.window?.location; const isLocal = - typeof window !== "undefined" && - (window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1" || - window.location.hostname.endsWith(".localhost")); + currentLocation?.hostname === "localhost" || + currentLocation?.hostname === "127.0.0.1" || + currentLocation?.hostname.endsWith(".localhost") === true; export const shellQuoteWord = (value: string): string => { if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'"'"'`)}'`; }; -interface DesktopBridge { - readonly getSettings: () => Promise<{ - readonly port: number; - readonly requireAuth: boolean; - readonly password: string; - }>; -} - -const readDesktopBridge = (): DesktopBridge | null => { - if (typeof window === "undefined") return null; - const candidate = (window as Window & { readonly executor?: DesktopBridge }).executor; - if (!candidate || typeof candidate.getSettings !== "function") return null; - return candidate; +const hasDesktopConnectionBridge = (): boolean => { + return Boolean(globalThis.window?.executor?.getServerConnection); }; export const buildMcpHttpEndpoint = (input: { readonly origin: string | null; - readonly desktop: { + readonly desktop?: { readonly port: number; } | null; readonly elicitationMode?: McpElicitationMode; @@ -69,7 +62,7 @@ export const buildMcpHttpEndpoint = (input: { }; const buildBasicAuthHeader = (password: string): string => { - // Renderer-only — every browser/Electron renderer has btoa. SSR doesn't + // Renderer-only: every browser/Electron renderer has btoa. SSR doesn't // render this card, so we don't need a Node fallback here. if (typeof globalThis.btoa !== "function") { return `Authorization: Basic executor:${password}`; @@ -87,6 +80,7 @@ export const buildMcpInstallCommand = (input: { readonly requireAuth: boolean; readonly password: string; } | null; + readonly authorizationHeader?: string | null; readonly elicitationMode?: McpElicitationMode; readonly devCliCwd?: string; }): string => { @@ -97,7 +91,9 @@ export const buildMcpInstallCommand = (input: { elicitationMode: input.elicitationMode, }); const headerFlags: string[] = []; - if (input.desktop?.requireAuth && input.desktop.password) { + if (input.authorizationHeader) { + headerFlags.push(`--header ${shellQuoteWord(`Authorization: ${input.authorizationHeader}`)}`); + } else if (input.desktop?.requireAuth && input.desktop.password) { headerFlags.push(`--header ${shellQuoteWord(buildBasicAuthHeader(input.desktop.password))}`); } const parts = [ @@ -122,38 +118,26 @@ export const buildMcpInstallCommand = (input: { }; export function McpInstallCard(props: { className?: string }) { - // Desktop hosts ship Electron without putting an `executor` binary on - // PATH, and the bundled sidecar is locked to the running app. Force the - // HTTP path there — it routes through the running sidecar with the - // Basic auth header injected by the renderer. - const showStdio = isLocal && readDesktopBridge() === null; const [mode, setMode] = useState("http"); const [advancedOpen, setAdvancedOpen] = useState(false); const [httpElicitationMode, setHttpElicitationMode] = useState("model"); - const [origin, setOrigin] = useState(null); - const [desktop, setDesktop] = useState<{ - readonly port: number; - readonly requireAuth: boolean; - readonly password: string; - } | null>(null); const scopeInfo = useScopeInfo(); - - useEffect(() => { - setOrigin(window.location.origin); - const bridge = readDesktopBridge(); - if (bridge) { - void bridge.getSettings().then(setDesktop, () => setDesktop(null)); - } - }, []); + const serverConnection = useExecutorServerConnection(); + // Desktop hosts ship Electron without putting an `executor` binary on + // PATH, and the bundled sidecar is locked to the running app. Force the + // HTTP path there; it routes through the active sidecar connection. + const showStdio = + isLocal && serverConnection.kind !== "desktop-sidecar" && !hasDesktopConnectionBridge(); const elicitationMode = mode === "stdio" ? "model" : httpElicitationMode; + const authorizationHeader = getExecutorServerAuthorizationHeader(serverConnection); const command = buildMcpInstallCommand({ mode, isDev, - origin, + origin: serverConnection.origin, scopeDir: scopeInfo.dir, - desktop, + authorizationHeader, elicitationMode, devCliCwd, }); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index 139ca10f5..1fa543439 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -36,7 +36,6 @@ const PUBLIC_PACKAGE_DIRS = [ "packages/core/cli", "packages/plugins/example", "packages/plugins/file-secrets", - "packages/plugins/google-discovery", "packages/plugins/graphql", "packages/plugins/keychain", "packages/plugins/mcp", diff --git a/scripts/smoke-test-packed.ts b/scripts/smoke-test-packed.ts index f9243cb5e..4b87c603e 100644 --- a/scripts/smoke-test-packed.ts +++ b/scripts/smoke-test-packed.ts @@ -42,7 +42,6 @@ const PUBLIC_PACKAGE_DIRS = [ "packages/core/cli", "packages/plugins/example", "packages/plugins/file-secrets", - "packages/plugins/google-discovery", "packages/plugins/graphql", "packages/plugins/keychain", "packages/plugins/mcp",