From f8472cedc6e23821006a3cd48fb1e6ed97d23baf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:20:26 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20configurable=20API?= =?UTF-8?q?=20server=20bind=20host=20experiment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ie1b6293b5a41c8f7dbba3589e64357736d1ee3ee Signed-off-by: Thomas Kosiewski --- .../Settings/sections/ExperimentsSection.tsx | 386 +++++++++++++++++- src/common/constants/experiments.ts | 10 + src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 29 ++ src/common/orpc/types.ts | 3 + src/common/types/project.ts | 13 + src/desktop/main.ts | 22 +- src/node/config.test.ts | 28 ++ src/node/config.ts | 36 ++ src/node/orpc/router.ts | 108 +++++ src/node/services/serverLockfile.test.ts | 14 + src/node/services/serverLockfile.ts | 28 +- src/node/services/serverService.test.ts | 100 ++++- src/node/services/serverService.ts | 147 ++++++- 14 files changed, 905 insertions(+), 20 deletions(-) diff --git a/src/browser/components/Settings/sections/ExperimentsSection.tsx b/src/browser/components/Settings/sections/ExperimentsSection.tsx index cc761a1a9b..26e9599491 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,332 @@ 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 [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); + } + + 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 }); + setStatus(next); + syncFormFromStatus(next); + } catch (e) { + setError(getErrorMessage(e)); + } finally { + setSaving(false); + } + }, [api, hostMode, portMode, customHost, fixedPort, syncFormFromStatus]); + + if (!enabled) { + return null; + } + + if (!api) { + return ( +
+
Connect to mux to configure this setting.
+
+ ); + } + + 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" + /> +
+ )} + +
+
+ {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.token && ( +
+
+
Auth token
+
{status.token}
+
+ +
+ )} +
+ )} +
+ ); +} + function StatsTabRow() { const { statsTabState, setStatsTabEnabled } = useFeatureFlags(); @@ -78,6 +420,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 +436,19 @@ export function ExperimentsSection() { }); }, [refreshWorkspaceMetadata]); + const handleConfigurableBindUrlToggle = useCallback( + (enabled: boolean) => { + if (enabled) { + return; + } + + api?.server.setApiServerSettings({ bindHost: null, port: null }).catch(() => { + // ignore + }); + }, + [api] + ); + return (

@@ -101,17 +457,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..4f7eb8f1b5 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]; @@ -52,6 +53,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, + }, [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE]: { id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE, name: "PTC Exclusive Mode", 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..be01a3a962 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -595,6 +595,24 @@ 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(), +}); export const server = { getLaunchProject: { input: z.void(), @@ -608,6 +626,17 @@ 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(), + }), + 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..b7c3c9f151 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -15,6 +15,19 @@ 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; /** 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..8f4c7ef3a1 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,29 @@ 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 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, 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..a8ce41ef88 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -44,6 +44,34 @@ describe("Config", () => { }); }); + describe("api server settings", () => { + it("should persist apiServerBindHost and apiServerPort", async () => { + await config.editConfig((cfg) => { + cfg.apiServerBindHost = "0.0.0.0"; + cfg.apiServerPort = 3000; + return cfg; + }); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.apiServerBindHost).toBe("0.0.0.0"); + expect(loaded.apiServerPort).toBe(3000); + }); + + 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..3a91d297b9 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -34,6 +34,26 @@ 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 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 +85,8 @@ export class Config { const data = fs.readFileSync(this.configFile, "utf-8"); const parsed = JSON.parse(data) as { projects?: unknown; + apiServerBindHost?: unknown; + apiServerPort?: unknown; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; @@ -83,6 +105,8 @@ export class Config { const projectsMap = new Map(normalizedPairs); return { projects: projectsMap, + apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), + apiServerPort: parseOptionalPort(parsed.apiServerPort), serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, taskSettings: normalizeTaskSettings(parsed.taskSettings), @@ -111,6 +135,8 @@ export class Config { const data: { projects: Array<[string, ProjectConfig]>; + apiServerBindHost?: string; + apiServerPort?: number; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; @@ -120,6 +146,16 @@ 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 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..0c1a9e873d 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -100,6 +100,114 @@ 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 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, + }; + }), + 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 prevPort = prevConfig.apiServerPort; + const wasRunning = context.serverService.isServerRunning(); + + const bindHost = input.bindHost?.trim() ? input.bindHost.trim() : undefined; + const port = input.port === null || input.port === 0 ? undefined : input.port; + + if (wasRunning) { + await context.serverService.stopServer(); + } + + await context.config.editConfig((config) => { + 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, + host: hostToUse, + port: portToUse, + }); + } catch (error) { + await context.config.editConfig((config) => { + 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, + 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 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, + }; + }), }, features: { getStatsTabState: t 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..6cca229968 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -1,11 +1,20 @@ import { createOrpcServer, type OrpcServer, type OrpcServerOptions } from "@/node/orpc/server"; import { ServerLockfile } from "./serverLockfile"; import type { ORPCContext } from "@/node/orpc/context"; +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 +22,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 +34,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 +170,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,9 +206,13 @@ 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 serverOptions: OrpcServerOptions = { - host: "127.0.0.1", + host: bindHost, port: options.port ?? 0, context: options.context, authToken: options.authToken, @@ -90,10 +221,15 @@ export class ServerService { }; 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 +240,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; From ecf123c707e78374dcae913794b50e7ad44d0ae2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 13:29:10 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20bracket=20IPv6=20host?= =?UTF-8?q?=20URLs=20in=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I9f29384286bd75aa25000758c887f608df99d607 Signed-off-by: Thomas Kosiewski --- src/node/orpc/server.test.ts | 52 ++++++++++++++++++++++++++++++++++++ src/node/orpc/server.ts | 26 +++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/node/orpc/server.test.ts diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts new file mode 100644 index 0000000000..84e6191723 --- /dev/null +++ b/src/node/orpc/server.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +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("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..61e5b357a6 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -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. * @@ -235,16 +252,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(); From dd6b598fa9986ac7fae42962032493e3c071d233 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 16:03:25 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20group=20PTC=20experim?= =?UTF-8?q?ents=20together?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I85049e30b2aa89f81782681cc0bba1154080594b Signed-off-by: Thomas Kosiewski --- src/common/constants/experiments.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 4f7eb8f1b5..81515c0fdc 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -53,6 +53,14 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, + [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE]: { + id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE, + name: "PTC Exclusive Mode", + description: "Replace all tools with code_execution (forces PTC usage)", + enabledByDefault: false, + userOverridable: true, + showInSettings: true, + }, [EXPERIMENT_IDS.CONFIGURABLE_BIND_URL]: { id: EXPERIMENT_IDS.CONFIGURABLE_BIND_URL, name: "Expose API server on LAN/VPN", @@ -62,14 +70,6 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, - [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE]: { - id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE, - name: "PTC Exclusive Mode", - description: "Replace all tools with code_execution (forces PTC usage)", - enabledByDefault: false, - userOverridable: true, - showInSettings: true, - }, }; /** From 040f09b56716d7b60c6ff0ece4d4b52ea52d9446 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 18:24:24 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20serve=20mux=20web=20?= =?UTF-8?q?UI=20from=20desktop=20API=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: If5babd087a2ccd55402daf74c4128dc64107cd9b Signed-off-by: Thomas Kosiewski --- .../Settings/sections/ExperimentsSection.tsx | 78 +++++++++++++++++-- src/common/orpc/schemas/api.ts | 3 + src/common/types/project.ts | 6 ++ src/desktop/main.ts | 2 + src/node/config.test.ts | 4 +- src/node/config.ts | 14 ++++ src/node/orpc/router.ts | 15 ++++ src/node/orpc/server.test.ts | 36 +++++++++ src/node/orpc/server.ts | 11 +-- src/node/services/serverService.ts | 18 ++++- 10 files changed, 175 insertions(+), 12 deletions(-) diff --git a/src/browser/components/Settings/sections/ExperimentsSection.tsx b/src/browser/components/Settings/sections/ExperimentsSection.tsx index 26e9599491..e20f12b671 100644 --- a/src/browser/components/Settings/sections/ExperimentsSection.tsx +++ b/src/browser/components/Settings/sections/ExperimentsSection.tsx @@ -82,6 +82,7 @@ function ConfigurableBindUrlControls() { 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(""); @@ -105,6 +106,7 @@ function ConfigurableBindUrlControls() { setCustomHost(configuredHost); } + setServeWebUi(next.configuredServeWebUi); const configuredPort = next.configuredPort; if (!configuredPort) { setPortMode("random"); @@ -205,7 +207,11 @@ function ConfigurableBindUrlControls() { setSaving(true); try { - const next = await api.server.setApiServerSettings({ bindHost, port }); + const next = await api.server.setApiServerSettings({ + bindHost, + port, + serveWebUi: serveWebUi ? true : null, + }); setStatus(next); syncFormFromStatus(next); } catch (e) { @@ -213,7 +219,7 @@ function ConfigurableBindUrlControls() { } finally { setSaving(false); } - }, [api, hostMode, portMode, customHost, fixedPort, syncFormFromStatus]); + }, [api, hostMode, portMode, customHost, fixedPort, serveWebUi, syncFormFromStatus]); if (!enabled) { return null; @@ -227,6 +233,14 @@ function ConfigurableBindUrlControls() { ); } + 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`) ?? []; @@ -304,6 +318,20 @@ function ConfigurableBindUrlControls() {
)} +
+
+
Serve mux web UI
+
+ Serve the mux web interface at / (browser mode) +
+
+ setServeWebUi(value)} + aria-label="Toggle serving mux web UI" + /> +
+
{loading @@ -375,6 +403,44 @@ function ConfigurableBindUrlControls() {
)} + {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 && (
@@ -442,9 +508,11 @@ export function ExperimentsSection() { return; } - api?.server.setApiServerSettings({ bindHost: null, port: null }).catch(() => { - // ignore - }); + api?.server + .setApiServerSettings({ bindHost: null, port: null, serveWebUi: null }) + .catch(() => { + // ignore + }); }, [api] ); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index be01a3a962..c4ec20ed83 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -612,6 +612,8 @@ export const ApiServerStatusSchema = z.object({ 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: { @@ -634,6 +636,7 @@ export const server = { input: z.object({ bindHost: z.string().nullable(), port: z.number().int().min(0).max(65535).nullable(), + serveWebUi: z.boolean().nullable().optional(), }), output: ApiServerStatusSchema, }, diff --git a/src/common/types/project.ts b/src/common/types/project.ts index b7c3c9f151..87b72fba70 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -28,6 +28,12 @@ export interface ProjectsConfig { * 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 8f4c7ef3a1..9aea6d825b 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -375,6 +375,7 @@ async function loadServices(): Promise { loadedConfig.apiServerBindHost.trim() ? loadedConfig.apiServerBindHost.trim() : undefined; + const serveStatic = loadedConfig.apiServerServeWebUi === true; const configuredPort = loadedConfig.apiServerPort; const envPortRaw = process.env.MUX_SERVER_PORT @@ -392,6 +393,7 @@ async function loadServices(): Promise { 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 a8ce41ef88..9598d6209c 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -45,16 +45,18 @@ describe("Config", () => { }); describe("api server settings", () => { - it("should persist apiServerBindHost and apiServerPort", async () => { + 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", () => { diff --git a/src/node/config.ts b/src/node/config.ts index 3a91d297b9..84cf38cd7b 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -43,6 +43,10 @@ function parseOptionalNonEmptyString(value: unknown): string | undefined { 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; @@ -87,6 +91,7 @@ export class Config { projects?: unknown; apiServerBindHost?: unknown; apiServerPort?: unknown; + apiServerServeWebUi?: unknown; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; @@ -106,6 +111,9 @@ export class Config { return { projects: projectsMap, apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), + apiServerServeWebUi: parseOptionalBoolean(parsed.apiServerServeWebUi) + ? true + : undefined, apiServerPort: parseOptionalPort(parsed.apiServerPort), serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, @@ -137,6 +145,7 @@ export class Config { projects: Array<[string, ProjectConfig]>; apiServerBindHost?: string; apiServerPort?: number; + apiServerServeWebUi?: boolean; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; @@ -151,6 +160,11 @@ export class Config { data.apiServerBindHost = apiServerBindHost; } + const apiServerServeWebUi = parseOptionalBoolean(config.apiServerServeWebUi); + if (apiServerServeWebUi) { + data.apiServerServeWebUi = true; + } + const apiServerPort = parseOptionalPort(config.apiServerPort); if (apiServerPort !== undefined) { data.apiServerPort = apiServerPort; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 0c1a9e873d..d21075d0ec 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -106,6 +106,7 @@ export const router = (authToken?: string) => { .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(); @@ -119,6 +120,7 @@ export const router = (authToken?: string) => { token: info?.token ?? null, configuredBindHost, configuredPort, + configuredServeWebUi, }; }), setApiServerSettings: t @@ -127,10 +129,17 @@ export const router = (authToken?: string) => { .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) { @@ -138,6 +147,7 @@ export const router = (authToken?: string) => { } await context.config.editConfig((config) => { + config.apiServerServeWebUi = serveWebUi; config.apiServerBindHost = bindHost; config.apiServerPort = port; return config; @@ -160,11 +170,13 @@ export const router = (authToken?: string) => { 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; @@ -178,6 +190,7 @@ export const router = (authToken?: string) => { await context.serverService.startServer({ muxHome: context.config.rootDir, context, + serveStatic: prevServeWebUi === true, authToken, host: hostToRestore, port: portToRestore, @@ -193,6 +206,7 @@ export const router = (authToken?: string) => { 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(); @@ -206,6 +220,7 @@ export const router = (authToken?: string) => { token: info?.token ?? null, configuredBindHost, configuredPort, + configuredServeWebUi, }; }), }, diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts index 84e6191723..9471ec487c 100644 --- a/src/node/orpc/server.test.ts +++ b/src/node/orpc/server.test.ts @@ -1,4 +1,7 @@ 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"; @@ -16,6 +19,39 @@ function getErrorCode(error: unknown): string | 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 = {}; diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index 61e5b357a6..7a97e3b79c 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -214,14 +214,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")); }); } diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index 6cca229968..65d21ae7fa 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -1,6 +1,9 @@ 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"; @@ -211,13 +214,26 @@ export class ServerService { 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: 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); From 3795275112f31cac369c94bebf4db618b15d7b02 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 18:57:47 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20auth=20token?= =?UTF-8?q?=20modal=20before=20initial=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I5436c9b73c6c4d46c4eb4584cc694fd25bc96fad Signed-off-by: Thomas Kosiewski --- .../components/AppLoader.auth.test.tsx | 44 +++++++++++++++++++ src/browser/components/AppLoader.tsx | 9 +++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/browser/components/AppLoader.auth.test.tsx 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 ; From e4ef80b3ed977aaac4459248f6e2c117218b7d51 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 18:57:59 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20logging=20ORP?= =?UTF-8?q?C=20unauthorized=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I6d189db020bdd8901aaedc21f1998fad40f1ceac Signed-off-by: Thomas Kosiewski --- src/node/orpc/server.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index 7a97e3b79c..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"; @@ -99,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