diff --git a/src/browser/components/AppLoader.auth.test.tsx b/src/browser/components/AppLoader.auth.test.tsx new file mode 100644 index 0000000000..fb2331920c --- /dev/null +++ b/src/browser/components/AppLoader.auth.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import { cleanup, render } from "@testing-library/react"; + +void mock.module("@/browser/contexts/API", () => ({ + APIProvider: (props: { children: React.ReactNode }) => props.children, + useAPI: () => ({ + api: null, + status: "auth_required" as const, + error: "Authentication required", + authenticate: () => undefined, + retry: () => undefined, + }), +})); + +void mock.module("@/browser/components/AuthTokenModal", () => ({ + AuthTokenModal: (props: { error?: string | null }) => ( +
{props.error ?? "no-error"}
+ ), +})); + +import { AppLoader } from "./AppLoader"; + +describe("AppLoader", () => { + beforeEach(() => { + const dom = new GlobalWindow(); + globalThis.window = dom as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("renders AuthTokenModal when API status is auth_required (before workspaces load)", () => { + const { getByTestId, queryByText } = render(); + + expect(queryByText("Loading workspaces...")).toBeNull(); + expect(getByTestId("AuthTokenModalMock").textContent).toContain("Authentication required"); + }); +}); diff --git a/src/browser/components/AppLoader.tsx b/src/browser/components/AppLoader.tsx index 89e20306b2..178604a80a 100644 --- a/src/browser/components/AppLoader.tsx +++ b/src/browser/components/AppLoader.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import App from "../App"; +import { AuthTokenModal } from "./AuthTokenModal"; import { LoadingScreen } from "./LoadingScreen"; import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore"; import { useGitStatusStoreRaw } from "../stores/GitStatusStore"; @@ -42,7 +43,8 @@ export function AppLoader(props: AppLoaderProps) { function AppLoaderInner() { const workspaceContext = useWorkspaceContext(); const projectContext = useProjectContext(); - const { api } = useAPI(); + const apiState = useAPI(); + const api = apiState.api; // Get store instances const workspaceStore = useWorkspaceStoreRaw(); @@ -73,6 +75,11 @@ function AppLoaderInner() { api, ]); + // If we're in browser mode and auth is required, show the token prompt before any data loads. + if (apiState.status === "auth_required") { + return ; + } + // Show loading screen until both projects and workspaces are loaded and stores synced if (projectContext.loading || workspaceContext.loading || !storesSynced) { return ; diff --git a/src/browser/components/Settings/sections/ExperimentsSection.tsx b/src/browser/components/Settings/sections/ExperimentsSection.tsx index cc761a1a9b..e20f12b671 100644 --- a/src/browser/components/Settings/sections/ExperimentsSection.tsx +++ b/src/browser/components/Settings/sections/ExperimentsSection.tsx @@ -1,11 +1,27 @@ -import React, { useCallback, useMemo } from "react"; -import { useExperiment, useRemoteExperimentValue } from "@/browser/contexts/ExperimentsContext"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useExperiment, + useExperimentValue, + useRemoteExperimentValue, +} from "@/browser/contexts/ExperimentsContext"; import { getExperimentList, EXPERIMENT_IDS, type ExperimentId, } from "@/common/constants/experiments"; import { Switch } from "@/browser/components/ui/switch"; +import { Button } from "@/browser/components/ui/button"; +import { CopyButton } from "@/browser/components/ui/CopyButton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import type { ApiServerStatus } from "@/common/orpc/types"; +import { Input } from "@/browser/components/ui/input"; +import { useAPI } from "@/browser/contexts/API"; import { useFeatureFlags } from "@/browser/contexts/FeatureFlagsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; @@ -48,6 +64,398 @@ function ExperimentRow(props: ExperimentRowProps) { ); } +type BindHostMode = "localhost" | "all" | "custom"; +type PortMode = "random" | "fixed"; + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function ConfigurableBindUrlControls() { + const enabled = useExperimentValue(EXPERIMENT_IDS.CONFIGURABLE_BIND_URL); + const { api } = useAPI(); + + const [status, setStatus] = useState(null); + const [hostMode, setHostMode] = useState("localhost"); + const [customHost, setCustomHost] = useState(""); + const [serveWebUi, setServeWebUi] = useState(false); + const [portMode, setPortMode] = useState("random"); + const [fixedPort, setFixedPort] = useState(""); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + + const syncFormFromStatus = useCallback((next: ApiServerStatus) => { + const configuredHost = next.configuredBindHost; + + if (!configuredHost || configuredHost === "127.0.0.1" || configuredHost === "localhost") { + setHostMode("localhost"); + setCustomHost(""); + } else if (configuredHost === "0.0.0.0") { + setHostMode("all"); + setCustomHost(""); + } else { + setHostMode("custom"); + setCustomHost(configuredHost); + } + + setServeWebUi(next.configuredServeWebUi); + const configuredPort = next.configuredPort; + if (!configuredPort) { + setPortMode("random"); + setFixedPort(""); + } else { + setPortMode("fixed"); + setFixedPort(String(configuredPort)); + } + }, []); + + const loadStatus = useCallback(async () => { + if (!api) { + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + setLoading(true); + setError(null); + + try { + const next = await api.server.getApiServerStatus(); + if (requestIdRef.current !== requestId) { + return; + } + + setStatus(next); + syncFormFromStatus(next); + } catch (e) { + if (requestIdRef.current !== requestId) { + return; + } + + setError(getErrorMessage(e)); + } finally { + if (requestIdRef.current === requestId) { + setLoading(false); + } + } + }, [api, syncFormFromStatus]); + + useEffect(() => { + if (!enabled) { + return; + } + + loadStatus().catch(() => { + // loadStatus handles error state + }); + }, [enabled, loadStatus]); + + const handleApply = useCallback(async () => { + if (!api) { + return; + } + + setError(null); + + let bindHost: string | null; + if (hostMode === "localhost") { + bindHost = null; + } else if (hostMode === "all") { + bindHost = "0.0.0.0"; + } else { + const trimmed = customHost.trim(); + if (!trimmed) { + setError("Custom bind host is required."); + return; + } + bindHost = trimmed; + } + + let port: number | null; + if (portMode === "random") { + port = null; + } else { + const parsed = Number.parseInt(fixedPort, 10); + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + setError("Port must be an integer."); + return; + } + + if (parsed === 0) { + setError("Port 0 means random. Choose “Random” instead."); + return; + } + + if (parsed < 1 || parsed > 65535) { + setError("Port must be between 1 and 65535."); + return; + } + + port = parsed; + } + + setSaving(true); + + try { + const next = await api.server.setApiServerSettings({ + bindHost, + port, + serveWebUi: serveWebUi ? true : null, + }); + setStatus(next); + syncFormFromStatus(next); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setSaving(false); + } + }, [api, hostMode, portMode, customHost, fixedPort, serveWebUi, syncFormFromStatus]); + + if (!enabled) { + return null; + } + + if (!api) { + return ( +
+
Connect to mux to configure this setting.
+
+ ); + } + + const encodedToken = status?.token ? encodeURIComponent(status.token) : null; + const localWebUiUrl = status?.baseUrl ? `${status.baseUrl}/` : null; + const localWebUiUrlWithToken = + status?.baseUrl && encodedToken ? `${status.baseUrl}/?token=${encodedToken}` : null; + const networkWebUiUrls = status?.networkBaseUrls.map((baseUrl) => `${baseUrl}/`) ?? []; + const networkWebUiUrlsWithToken = encodedToken + ? (status?.networkBaseUrls.map((baseUrl) => `${baseUrl}/?token=${encodedToken}`) ?? []) + : []; + const localDocsUrl = status?.baseUrl ? `${status.baseUrl}/api/docs` : null; + const networkDocsUrls = status?.networkBaseUrls.map((baseUrl) => `${baseUrl}/api/docs`) ?? []; + + return ( +
+
+ Exposes mux’s API server to your LAN/VPN. Devices on your local network can connect if they + have the auth token. Traffic is unencrypted HTTP; enable only on trusted networks (Tailscale + recommended). +
+ +
+
+
+
Bind host
+
Where mux listens for HTTP + WS connections
+
+ +
+ + {hostMode === "custom" && ( +
+
+
Custom host
+
Example: 192.168.1.10 or 100.x.y.z
+
+ ) => setCustomHost(e.target.value)} + placeholder="e.g. 192.168.1.10" + className="border-border-medium bg-background-secondary h-9 w-64" + /> +
+ )} + +
+
+
Port
+
+ Use a fixed port to avoid changing URLs each time mux restarts +
+
+ +
+ + {portMode === "fixed" && ( +
+
+
Fixed port
+
1–65535
+
+ ) => setFixedPort(e.target.value)} + placeholder="e.g. 9999" + className="border-border-medium bg-background-secondary h-9 w-64" + /> +
+ )} + +
+
+
Serve mux web UI
+
+ Serve the mux web interface at / (browser mode) +
+
+ setServeWebUi(value)} + aria-label="Toggle serving mux web UI" + /> +
+ +
+
+ {loading + ? "Loading server status…" + : status?.running + ? "Server is running" + : "Server is not running"} +
+
+ + +
+
+ + {error &&
{error}
} +
+ + {status && ( +
+
Connection info
+ + {localDocsUrl && ( +
+
+
Local docs URL
+
{localDocsUrl}
+
+ +
+ )} + + {networkDocsUrls.length > 0 ? ( +
+ {networkDocsUrls.map((docsUrl) => ( +
+
+
Network docs URL
+
{docsUrl}
+
+ +
+ ))} +
+ ) : ( +
+ No network URLs detected (bind host may still be localhost). +
+ )} + + {status.configuredServeWebUi ? ( + <> + {(localWebUiUrlWithToken ?? localWebUiUrl) && ( +
+
+
Local web UI URL
+
+ {localWebUiUrlWithToken ?? localWebUiUrl} +
+
+ +
+ )} + + {(encodedToken ? networkWebUiUrlsWithToken : networkWebUiUrls).length > 0 ? ( +
+ {(encodedToken ? networkWebUiUrlsWithToken : networkWebUiUrls).map((uiUrl) => ( +
+
+
Network web UI URL
+
{uiUrl}
+
+ +
+ ))} +
+ ) : ( +
+ No network URLs detected for the web UI (bind host may still be localhost). +
+ )} + + ) : ( +
+ Web UI serving is disabled (enable “Serve mux web UI” and Apply to access /). +
+ )} + + {status.token && ( +
+
+
Auth token
+
{status.token}
+
+ +
+ )} +
+ )} +
+ ); +} + function StatsTabRow() { const { statsTabState, setStatsTabEnabled } = useFeatureFlags(); @@ -78,6 +486,7 @@ function StatsTabRow() { export function ExperimentsSection() { const allExperiments = getExperimentList(); const { refreshWorkspaceMetadata } = useWorkspaceContext(); + const { api } = useAPI(); // Only show user-overridable experiments (non-overridable ones are hidden since users can't change them) const experiments = useMemo( @@ -93,6 +502,21 @@ export function ExperimentsSection() { }); }, [refreshWorkspaceMetadata]); + const handleConfigurableBindUrlToggle = useCallback( + (enabled: boolean) => { + if (enabled) { + return; + } + + api?.server + .setApiServerSettings({ bindHost: null, port: null, serveWebUi: null }) + .catch(() => { + // ignore + }); + }, + [api] + ); + return (

@@ -101,17 +525,21 @@ export function ExperimentsSection() {

{experiments.map((exp) => ( - + + + {exp.id === EXPERIMENT_IDS.CONFIGURABLE_BIND_URL && } + ))}
{experiments.length === 0 && ( diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 6a5d984555..81515c0fdc 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -9,6 +9,7 @@ export const EXPERIMENT_IDS = { POST_COMPACTION_CONTEXT: "post-compaction-context", PROGRAMMATIC_TOOL_CALLING: "programmatic-tool-calling", PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive", + CONFIGURABLE_BIND_URL: "configurable-bind-url", } as const; export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS]; @@ -60,6 +61,15 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, + [EXPERIMENT_IDS.CONFIGURABLE_BIND_URL]: { + id: EXPERIMENT_IDS.CONFIGURABLE_BIND_URL, + name: "Expose API server on LAN/VPN", + description: + "Allow mux to listen on a non-localhost address so other devices on your LAN/VPN can connect. Anyone on your network with the auth token can access your mux API. HTTP only; use only on trusted networks (Tailscale recommended).", + enabledByDefault: false, + userOverridable: true, + showInSettings: true, + }, }; /** diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 75227459fb..2ebcf2ceca 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -113,6 +113,7 @@ export { // API router schemas export { + ApiServerStatusSchema, AWSCredentialStatusSchema, config, debug, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8228209a06..c4ec20ed83 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -595,6 +595,26 @@ export const terminal = { }; // Server + +export const ApiServerStatusSchema = z.object({ + running: z.boolean(), + /** Base URL that is always connectable from the local machine (loopback for wildcard binds). */ + baseUrl: z.string().nullable(), + /** The host/interface the server is actually bound to. */ + bindHost: z.string().nullable(), + /** The port the server is listening on. */ + port: z.number().int().min(0).max(65535).nullable(), + /** Additional base URLs that may be reachable from other devices (LAN/VPN). */ + networkBaseUrls: z.array(z.url()), + /** Auth token required for HTTP/WS API access. */ + token: z.string().nullable(), + /** Configured bind host from ~/.mux/config.json (if set). */ + configuredBindHost: z.string().nullable(), + /** Configured port from ~/.mux/config.json (if set). */ + configuredPort: z.number().int().min(0).max(65535).nullable(), + /** Whether the API server should serve the mux web UI at /. */ + configuredServeWebUi: z.boolean(), +}); export const server = { getLaunchProject: { input: z.void(), @@ -608,6 +628,18 @@ export const server = { input: z.object({ sshHost: z.string().nullable() }), output: z.void(), }, + getApiServerStatus: { + input: z.void(), + output: ApiServerStatusSchema, + }, + setApiServerSettings: { + input: z.object({ + bindHost: z.string().nullable(), + port: z.number().int().min(0).max(65535).nullable(), + serveWebUi: z.boolean().nullable().optional(), + }), + output: ApiServerStatusSchema, + }, }; // Config (global settings) diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index d7cb32553d..fc3f6230f3 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -36,6 +36,9 @@ export type FrontendWorkspaceMetadataSchemaType = z.infer< typeof schemas.FrontendWorkspaceMetadataSchema >; +// Server types (single source of truth - derived from schemas) +export type ApiServerStatus = z.infer; + // Experiment types (single source of truth - derived from schemas) export type ExperimentValue = z.infer; diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 3365f5062c..87b72fba70 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -15,6 +15,25 @@ export type FeatureFlagOverride = "default" | "on" | "off"; export interface ProjectsConfig { projects: Map; + /** + * Bind host/interface for the desktop HTTP/WS API server. + * + * When unset, mux binds to 127.0.0.1 (localhost only). + * When set to 0.0.0.0 or ::, mux can be reachable from other devices on your LAN/VPN. + */ + apiServerBindHost?: string; + /** + * Port for the desktop HTTP/WS API server. + * + * When unset, mux binds to port 0 (random available port). + */ + apiServerPort?: number; + /** + * When true, the desktop HTTP server also serves the mux web UI at /. + * + * This enables other devices (LAN/VPN) to open mux in a browser. + */ + apiServerServeWebUi?: boolean; /** SSH hostname/alias for this machine (used for editor deep links in browser mode) */ serverSshHost?: string; /** IDs of splash screens that have been viewed */ diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 1e3e404bc7..9aea6d825b 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -309,6 +309,9 @@ async function loadServices(): Promise { // Generate auth token (use env var or random per-session) const authToken = process.env.MUX_SERVER_AUTH_TOKEN ?? randomBytes(32).toString("hex"); + // Store auth token so the API server can be restarted via Settings. + services.serverService.setApiAuthToken(authToken); + // Single router instance with auth middleware - used for both MessagePort and HTTP/WS const orpcRouter = router(authToken); @@ -366,12 +369,31 @@ async function loadServices(): Promise { console.log(`[${timestamp()}] API server already running at ${existing.baseUrl}, skipping`); } else { try { - const port = process.env.MUX_SERVER_PORT ? parseInt(process.env.MUX_SERVER_PORT, 10) : 0; + const loadedConfig = config.loadConfigOrDefault(); + const configuredBindHost = + typeof loadedConfig.apiServerBindHost === "string" && + loadedConfig.apiServerBindHost.trim() + ? loadedConfig.apiServerBindHost.trim() + : undefined; + const serveStatic = loadedConfig.apiServerServeWebUi === true; + const configuredPort = loadedConfig.apiServerPort; + + const envPortRaw = process.env.MUX_SERVER_PORT + ? Number.parseInt(process.env.MUX_SERVER_PORT, 10) + : undefined; + const envPort = + envPortRaw !== undefined && Number.isFinite(envPortRaw) ? envPortRaw : undefined; + + const port = envPort ?? configuredPort ?? 0; + const host = configuredBindHost ?? "127.0.0.1"; + const serverInfo = await services.serverService.startServer({ muxHome: config.rootDir, context: orpcContext, router: orpcRouter, authToken, + host, + serveStatic, port, }); console.log(`[${timestamp()}] API server started at ${serverInfo.baseUrl}`); diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 783c389668..9598d6209c 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -44,6 +44,36 @@ describe("Config", () => { }); }); + describe("api server settings", () => { + it("should persist apiServerBindHost, apiServerPort, and apiServerServeWebUi", async () => { + await config.editConfig((cfg) => { + cfg.apiServerBindHost = "0.0.0.0"; + cfg.apiServerPort = 3000; + cfg.apiServerServeWebUi = true; + return cfg; + }); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.apiServerBindHost).toBe("0.0.0.0"); + expect(loaded.apiServerPort).toBe(3000); + expect(loaded.apiServerServeWebUi).toBe(true); + }); + + it("should ignore invalid apiServerPort values on load", () => { + const configFile = path.join(tempDir, "config.json"); + fs.writeFileSync( + configFile, + JSON.stringify({ + projects: [], + apiServerPort: 70000, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.apiServerPort).toBeUndefined(); + }); + }); + describe("generateStableId", () => { it("should generate a 10-character hex string", () => { const id = config.generateStableId(); diff --git a/src/node/config.ts b/src/node/config.ts index 0593c560db..84cf38cd7b 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -34,6 +34,30 @@ export interface ProviderConfig { [key: string]: unknown; } +function parseOptionalNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function parseOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function parseOptionalPort(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { + return undefined; + } + + if (value < 0 || value > 65535) { + return undefined; + } + + return value; +} export type ProvidersConfig = Record; /** @@ -65,6 +89,9 @@ export class Config { const data = fs.readFileSync(this.configFile, "utf-8"); const parsed = JSON.parse(data) as { projects?: unknown; + apiServerBindHost?: unknown; + apiServerPort?: unknown; + apiServerServeWebUi?: unknown; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; @@ -83,6 +110,11 @@ export class Config { const projectsMap = new Map(normalizedPairs); return { projects: projectsMap, + apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), + apiServerServeWebUi: parseOptionalBoolean(parsed.apiServerServeWebUi) + ? true + : undefined, + apiServerPort: parseOptionalPort(parsed.apiServerPort), serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, taskSettings: normalizeTaskSettings(parsed.taskSettings), @@ -111,6 +143,9 @@ export class Config { const data: { projects: Array<[string, ProjectConfig]>; + apiServerBindHost?: string; + apiServerPort?: number; + apiServerServeWebUi?: boolean; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; @@ -120,6 +155,21 @@ export class Config { projects: Array.from(config.projects.entries()), taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, }; + const apiServerBindHost = parseOptionalNonEmptyString(config.apiServerBindHost); + if (apiServerBindHost) { + data.apiServerBindHost = apiServerBindHost; + } + + const apiServerServeWebUi = parseOptionalBoolean(config.apiServerServeWebUi); + if (apiServerServeWebUi) { + data.apiServerServeWebUi = true; + } + + const apiServerPort = parseOptionalPort(config.apiServerPort); + if (apiServerPort !== undefined) { + data.apiServerPort = apiServerPort; + } + if (config.serverSshHost) { data.serverSshHost = config.serverSshHost; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index fd69479797..d21075d0ec 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -100,6 +100,129 @@ export const router = (authToken?: string) => { serverSshHost: input.sshHost ?? undefined, })); }), + getApiServerStatus: t + .input(schemas.server.getApiServerStatus.input) + .output(schemas.server.getApiServerStatus.output) + .handler(({ context }) => { + const config = context.config.loadConfigOrDefault(); + const configuredBindHost = config.apiServerBindHost ?? null; + const configuredServeWebUi = config.apiServerServeWebUi === true; + const configuredPort = config.apiServerPort ?? null; + + const info = context.serverService.getServerInfo(); + + return { + running: info !== null, + baseUrl: info?.baseUrl ?? null, + bindHost: info?.bindHost ?? null, + port: info?.port ?? null, + networkBaseUrls: info?.networkBaseUrls ?? [], + token: info?.token ?? null, + configuredBindHost, + configuredPort, + configuredServeWebUi, + }; + }), + setApiServerSettings: t + .input(schemas.server.setApiServerSettings.input) + .output(schemas.server.setApiServerSettings.output) + .handler(async ({ context, input }) => { + const prevConfig = context.config.loadConfigOrDefault(); + const prevBindHost = prevConfig.apiServerBindHost; + const prevServeWebUi = prevConfig.apiServerServeWebUi; + const prevPort = prevConfig.apiServerPort; + const wasRunning = context.serverService.isServerRunning(); + + const bindHost = input.bindHost?.trim() ? input.bindHost.trim() : undefined; + const serveWebUi = + input.serveWebUi === undefined + ? prevServeWebUi + : input.serveWebUi === true + ? true + : undefined; + const port = input.port === null || input.port === 0 ? undefined : input.port; + + if (wasRunning) { + await context.serverService.stopServer(); + } + + await context.config.editConfig((config) => { + config.apiServerServeWebUi = serveWebUi; + config.apiServerBindHost = bindHost; + config.apiServerPort = port; + return config; + }); + + if (process.env.MUX_NO_API_SERVER !== "1") { + const authToken = context.serverService.getApiAuthToken(); + if (!authToken) { + throw new Error("API server auth token not initialized"); + } + + const envPort = process.env.MUX_SERVER_PORT + ? Number.parseInt(process.env.MUX_SERVER_PORT, 10) + : undefined; + const portToUse = envPort ?? port ?? 0; + const hostToUse = bindHost ?? "127.0.0.1"; + + try { + await context.serverService.startServer({ + muxHome: context.config.rootDir, + context, + authToken, + serveStatic: serveWebUi === true, + host: hostToUse, + port: portToUse, + }); + } catch (error) { + await context.config.editConfig((config) => { + config.apiServerServeWebUi = prevServeWebUi; + config.apiServerBindHost = prevBindHost; + config.apiServerPort = prevPort; + return config; + }); + + if (wasRunning) { + const portToRestore = envPort ?? prevPort ?? 0; + const hostToRestore = prevBindHost ?? "127.0.0.1"; + + try { + await context.serverService.startServer({ + muxHome: context.config.rootDir, + context, + serveStatic: prevServeWebUi === true, + authToken, + host: hostToRestore, + port: portToRestore, + }); + } catch { + // Best effort - we'll surface the original error. + } + } + + throw error; + } + } + + const nextConfig = context.config.loadConfigOrDefault(); + const configuredBindHost = nextConfig.apiServerBindHost ?? null; + const configuredServeWebUi = nextConfig.apiServerServeWebUi === true; + const configuredPort = nextConfig.apiServerPort ?? null; + + const info = context.serverService.getServerInfo(); + + return { + running: info !== null, + baseUrl: info?.baseUrl ?? null, + bindHost: info?.bindHost ?? null, + port: info?.port ?? null, + networkBaseUrls: info?.networkBaseUrls ?? [], + token: info?.token ?? null, + configuredBindHost, + configuredPort, + configuredServeWebUi, + }; + }), }, features: { getStatsTabState: t diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts new file mode 100644 index 0000000000..9471ec487c --- /dev/null +++ b/src/node/orpc/server.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { createOrpcServer } from "./server"; +import type { ORPCContext } from "./context"; + +function getErrorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null) { + return null; + } + + if (!("code" in error)) { + return null; + } + + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : null; +} + +describe("createOrpcServer", () => { + test("serveStatic fallback does not swallow /api routes", async () => { + // Minimal context stub - router won't be exercised by this test. + const stubContext: Partial = {}; + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-")); + const indexHtml = "mux
ok
"; + + let server: Awaited> | null = null; + + try { + await fs.writeFile(path.join(tempDir, "index.html"), indexHtml, "utf-8"); + + server = await createOrpcServer({ + host: "127.0.0.1", + port: 0, + context: stubContext as ORPCContext, + authToken: "test-token", + serveStatic: true, + staticDir: tempDir, + }); + + const uiRes = await fetch(`${server.baseUrl}/some/spa/route`); + expect(uiRes.status).toBe(200); + expect(await uiRes.text()).toContain("mux"); + + const apiRes = await fetch(`${server.baseUrl}/api/not-a-real-route`); + expect(apiRes.status).toBe(404); + } finally { + await server?.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + test("brackets IPv6 hosts in returned URLs", async () => { + // Minimal context stub - router won't be exercised by this test. + const stubContext: Partial = {}; + + let server: Awaited> | null = null; + + try { + server = await createOrpcServer({ + host: "::1", + port: 0, + context: stubContext as ORPCContext, + authToken: "test-token", + }); + } catch (error) { + const code = getErrorCode(error); + + // Some CI environments may not have IPv6 enabled. + if (code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL") { + return; + } + + throw error; + } + + try { + expect(server.baseUrl).toMatch(/^http:\/\/\[::1\]:\d+$/); + expect(server.wsUrl).toMatch(/^ws:\/\/\[::1\]:\d+\/orpc\/ws$/); + expect(server.specUrl).toMatch(/^http:\/\/\[::1\]:\d+\/api\/spec\.json$/); + expect(server.docsUrl).toMatch(/^http:\/\/\[::1\]:\d+\/api\/docs$/); + } finally { + await server.close(); + } + }); +}); diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index eea3abd348..f49582bb25 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -12,7 +12,7 @@ import * as path from "path"; import { WebSocketServer } from "ws"; import { RPCHandler } from "@orpc/server/node"; import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws"; -import { onError } from "@orpc/server"; +import { ORPCError, onError } from "@orpc/server"; import { OpenAPIGenerator } from "@orpc/openapi"; import { OpenAPIHandler } from "@orpc/openapi/node"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; @@ -66,6 +66,23 @@ export interface OrpcServer { // --- Server Factory --- +function formatHostForUrl(host: string): string { + const trimmed = host.trim(); + + // IPv6 URLs must be bracketed: http://[::1]:1234 + if (trimmed.includes(":")) { + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed; + } + + // If the host contains a zone index (e.g. fe80::1%en0), percent must be encoded. + const escaped = trimmed.replaceAll("%", "%25"); + return `[${escaped}]`; + } + + return trimmed; +} + /** * Create an oRPC server with HTTP and WebSocket endpoints. * @@ -82,7 +99,16 @@ export async function createOrpcServer({ serveStatic = false, // From dist/node/orpc/, go up 2 levels to reach dist/ where index.html lives staticDir = path.join(__dirname, "../.."), - onOrpcError = (error) => log.error("ORPC Error:", error), + onOrpcError = (error) => { + // Auth failures are expected in browser mode while the user enters the token. + // Avoid spamming error logs with stack traces on every unauthenticated request. + if (error instanceof ORPCError && error.code === "UNAUTHORIZED") { + log.debug("ORPC unauthorized request"); + return; + } + + log.error("ORPC Error:", error); + }, router: existingRouter, }: OrpcServerOptions): Promise { // Express app setup @@ -197,14 +223,15 @@ export async function createOrpcServer({ next(); }); - // SPA fallback (optional, only for non-orpc routes) + // SPA fallback (optional, only for non-API routes) if (serveStatic) { app.use((req, res, next) => { - if (!req.path.startsWith("/orpc")) { - res.sendFile(path.join(staticDir, "index.html")); - } else { - next(); + // Don't swallow API/ORPC routes with index.html. + if (req.path.startsWith("/orpc") || req.path.startsWith("/api")) { + return next(); } + + res.sendFile(path.join(staticDir, "index.html")); }); } @@ -235,16 +262,17 @@ export async function createOrpcServer({ // Wildcard addresses (0.0.0.0, ::) are not routable - convert to loopback for lockfile const connectableHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; + const connectableHostForUrl = formatHostForUrl(connectableHost); return { httpServer, wsServer, app, port: actualPort, - baseUrl: `http://${connectableHost}:${actualPort}`, - wsUrl: `ws://${connectableHost}:${actualPort}/orpc/ws`, - specUrl: `http://${connectableHost}:${actualPort}/api/spec.json`, - docsUrl: `http://${connectableHost}:${actualPort}/api/docs`, + baseUrl: `http://${connectableHostForUrl}:${actualPort}`, + wsUrl: `ws://${connectableHostForUrl}:${actualPort}/orpc/ws`, + specUrl: `http://${connectableHostForUrl}:${actualPort}/api/spec.json`, + docsUrl: `http://${connectableHostForUrl}:${actualPort}/api/docs`, close: async () => { // Close WebSocket server first wsServer.close(); diff --git a/src/node/services/serverLockfile.test.ts b/src/node/services/serverLockfile.test.ts index 2e66d9eca1..3066812f0e 100644 --- a/src/node/services/serverLockfile.test.ts +++ b/src/node/services/serverLockfile.test.ts @@ -28,6 +28,20 @@ describe("ServerLockfile", () => { expect(data!.startedAt).toBeDefined(); }); + test("acquire persists optional network metadata", async () => { + await lockfile.acquire("http://localhost:12345", "test-token", { + bindHost: "0.0.0.0", + port: 12345, + networkBaseUrls: ["http://192.168.1.10:12345"], + }); + + const data = await lockfile.read(); + expect(data).not.toBeNull(); + expect(data!.bindHost).toBe("0.0.0.0"); + expect(data!.port).toBe(12345); + expect(data!.networkBaseUrls).toEqual(["http://192.168.1.10:12345"]); + }); + test("read returns null for non-existent lockfile", async () => { const data = await lockfile.read(); expect(data).toBeNull(); diff --git a/src/node/services/serverLockfile.ts b/src/node/services/serverLockfile.ts index 60525bdd78..9dac5c2ff2 100644 --- a/src/node/services/serverLockfile.ts +++ b/src/node/services/serverLockfile.ts @@ -8,6 +8,12 @@ export const ServerLockDataSchema = z.object({ baseUrl: z.url(), token: z.string(), startedAt: z.string(), + /** Bind host/interface the server is listening on (e.g. "127.0.0.1" or "0.0.0.0") */ + bindHost: z.string().optional(), + /** The port the server is listening on */ + port: z.number().int().min(0).max(65535).optional(), + /** Additional base URLs that are reachable from other devices (LAN/VPN) */ + networkBaseUrls: z.array(z.url()).optional(), }); export type ServerLockData = z.infer; @@ -29,12 +35,32 @@ export class ServerLockfile { * Acquire the lockfile with the given baseUrl and token. * Writes atomically with 0600 permissions (owner read/write only). */ - async acquire(baseUrl: string, token: string): Promise { + async acquire( + baseUrl: string, + token: string, + extra?: { + bindHost?: string; + port?: number; + networkBaseUrls?: string[]; + } + ): Promise { + const bindHost = extra?.bindHost?.trim() ? extra.bindHost.trim() : undefined; + const port = + typeof extra?.port === "number" && + Number.isInteger(extra.port) && + extra.port >= 0 && + extra.port <= 65535 + ? extra.port + : undefined; + const data: ServerLockData = { pid: process.pid, baseUrl, token, startedAt: new Date().toISOString(), + bindHost, + port, + networkBaseUrls: extra?.networkBaseUrls?.length ? extra.networkBaseUrls : undefined, }; // Ensure directory exists diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index 25676aaac8..c368fb8516 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; import * as net from "net"; -import { ServerService } from "./serverService"; +import { ServerService, computeNetworkBaseUrls } from "./serverService"; import type { ORPCContext } from "@/node/orpc/context"; import { ServerLockDataSchema } from "./serverLockfile"; @@ -179,3 +179,101 @@ describe("ServerService.startServer", () => { } }); }); + +describe("computeNetworkBaseUrls", () => { + test("returns empty for loopback binds", () => { + expect(computeNetworkBaseUrls({ bindHost: "127.0.0.1", port: 3000 })).toEqual([]); + expect(computeNetworkBaseUrls({ bindHost: "localhost", port: 3000 })).toEqual([]); + expect(computeNetworkBaseUrls({ bindHost: "::1", port: 3000 })).toEqual([]); + }); + + test("expands 0.0.0.0 to all non-internal IPv4 interfaces", () => { + const networkInterfaces: ReturnType = { + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + en0: [ + { + address: "192.168.1.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "192.168.1.10/24", + }, + ], + tailscale0: [ + { + address: "100.64.0.2", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:01", + internal: false, + cidr: "100.64.0.2/10", + }, + ], + docker0: [ + { + address: "169.254.1.2", + netmask: "255.255.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:02", + internal: false, + cidr: "169.254.1.2/16", + }, + ], + }; + + expect( + computeNetworkBaseUrls({ + bindHost: "0.0.0.0", + port: 3000, + networkInterfaces, + }) + ).toEqual(["http://100.64.0.2:3000", "http://192.168.1.10:3000"]); + }); + + test("formats IPv6 URLs with brackets", () => { + const networkInterfaces: ReturnType = { + en0: [ + { + address: "fd7a:115c:a1e0::1", + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "fd7a:115c:a1e0::1/64", + scopeid: 0, + }, + { + address: "fe80::1", + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "fe80::1/64", + scopeid: 0, + }, + ], + }; + + expect( + computeNetworkBaseUrls({ + bindHost: "::", + port: 3000, + networkInterfaces, + }) + ).toEqual(["http://[fd7a:115c:a1e0::1]:3000"]); + + expect(computeNetworkBaseUrls({ bindHost: "2001:db8::1", port: 3000 })).toEqual([ + "http://[2001:db8::1]:3000", + ]); + }); +}); diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index d340bafc8c..65d21ae7fa 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -1,11 +1,23 @@ import { createOrpcServer, type OrpcServer, type OrpcServerOptions } from "@/node/orpc/server"; import { ServerLockfile } from "./serverLockfile"; import type { ORPCContext } from "@/node/orpc/context"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { log } from "./log"; +import * as os from "os"; import type { AppRouter } from "@/node/orpc/router"; export interface ServerInfo { + /** Base URL that is always connectable from the local machine (loopback for wildcard binds). */ baseUrl: string; + /** Auth token required for HTTP/WS API access. */ token: string; + /** The host/interface the server is actually bound to (e.g. "127.0.0.1" or "0.0.0.0"). */ + bindHost: string; + /** The port the server is listening on. */ + port: number; + /** Additional base URLs that may be reachable from other devices (LAN/VPN). */ + networkBaseUrls: string[]; } export interface StartServerOptions { @@ -13,6 +25,8 @@ export interface StartServerOptions { muxHome: string; /** oRPC context with services */ context: ORPCContext; + /** Host/interface to bind to (default: "127.0.0.1") */ + host?: string; /** Auth token for the server */ authToken: string; /** Port to bind to (0 = random) */ @@ -23,10 +37,111 @@ export interface StartServerOptions { serveStatic?: boolean; } +type NetworkInterfaces = NodeJS.Dict; + +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; +} + +function formatHostForUrl(host: string): string { + const trimmed = host.trim(); + + // IPv6 URLs must be bracketed: http://[::1]:1234 + if (trimmed.includes(":")) { + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed; + } + + return `[${trimmed}]`; + } + + return trimmed; +} + +function buildHttpBaseUrl(host: string, port: number): string { + return `http://${formatHostForUrl(host)}:${port}`; +} + +function getNonInternalInterfaceAddresses( + networkInterfaces: NetworkInterfaces, + family: "IPv4" | "IPv6" +): string[] { + const addresses: string[] = []; + const emptyInfos: os.NetworkInterfaceInfo[] = []; + + for (const name of Object.keys(networkInterfaces)) { + const infos: os.NetworkInterfaceInfo[] = networkInterfaces[name] ?? emptyInfos; + for (const info of infos) { + const infoFamily = info.family; + + if (infoFamily !== family) { + continue; + } + + if (info.internal) { + continue; + } + + const address = info.address; + + // Filter out link-local addresses (they are rarely what users want to copy/paste). + if (family === "IPv4" && address.startsWith("169.254.")) { + continue; + } + if (family === "IPv6" && address.toLowerCase().startsWith("fe80:")) { + continue; + } + + addresses.push(address); + } + } + + return Array.from(new Set(addresses)).sort(); +} + +/** + * Compute base URLs that are reachable from other devices (LAN/VPN). + * + * NOTE: This is for UI/display and should not be used for lockfile discovery, + * since lockfiles are local-machine concerns. + */ +export function computeNetworkBaseUrls(options: { + bindHost: string; + port: number; + networkInterfaces?: NetworkInterfaces; +}): string[] { + const bindHost = options.bindHost.trim(); + if (!bindHost) { + return []; + } + + if (isLoopbackHost(bindHost)) { + return []; + } + + const networkInterfaces = options.networkInterfaces ?? os.networkInterfaces(); + + if (bindHost === "0.0.0.0") { + return getNonInternalInterfaceAddresses(networkInterfaces, "IPv4").map((address) => + buildHttpBaseUrl(address, options.port) + ); + } + + if (bindHost === "::") { + return getNonInternalInterfaceAddresses(networkInterfaces, "IPv6").map((address) => + buildHttpBaseUrl(address, options.port) + ); + } + + return [buildHttpBaseUrl(bindHost, options.port)]; +} + export class ServerService { private launchProjectPath: string | null = null; private server: OrpcServer | null = null; private lockfile: ServerLockfile | null = null; + private apiAuthToken: string | null = null; private serverInfo: ServerInfo | null = null; private sshHost: string | undefined = undefined; @@ -58,6 +173,21 @@ export class ServerService { return this.sshHost; } + /** + * Set the auth token used for the HTTP/WS API server. + * + * This is injected by the desktop app on startup so the server can be restarted + * without needing to plumb the token through every callsite. + */ + setApiAuthToken(token: string): void { + this.apiAuthToken = token; + } + + /** Get the auth token used for the HTTP/WS API server (if initialized). */ + getApiAuthToken(): string | null { + return this.apiAuthToken; + } + /** * Start the HTTP/WS API server. * @@ -79,21 +209,43 @@ export class ServerService { ); } - // Create the server (Electron always binds to 127.0.0.1) + const bindHost = + typeof options.host === "string" && options.host.trim() ? options.host.trim() : "127.0.0.1"; + + this.apiAuthToken = options.authToken; + + const staticDir = path.join(__dirname, "../.."); + let serveStatic = options.serveStatic ?? false; + if (serveStatic) { + const indexPath = path.join(staticDir, "index.html"); + try { + await fs.access(indexPath); + } catch { + log.warn(`API server static UI requested, but ${indexPath} is missing. Disabling.`); + serveStatic = false; + } + } + const serverOptions: OrpcServerOptions = { - host: "127.0.0.1", + host: bindHost, port: options.port ?? 0, context: options.context, authToken: options.authToken, router: options.router, - serveStatic: options.serveStatic ?? false, + serveStatic, + staticDir, }; const server = await createOrpcServer(serverOptions); + const networkBaseUrls = computeNetworkBaseUrls({ bindHost, port: server.port }); // Acquire the lockfile - clean up server if this fails try { - await lockfile.acquire(server.baseUrl, options.authToken); + await lockfile.acquire(server.baseUrl, options.authToken, { + bindHost, + port: server.port, + networkBaseUrls, + }); } catch (err) { await server.close(); throw err; @@ -104,8 +256,11 @@ export class ServerService { this.lockfile = lockfile; this.server = server; this.serverInfo = { - baseUrl: this.server.baseUrl, + baseUrl: server.baseUrl, token: options.authToken, + bindHost, + port: server.port, + networkBaseUrls, }; return this.serverInfo;