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
+
+
setHostMode(value as BindHostMode)}>
+
+
+
+
+ Localhost only (127.0.0.1)
+ All interfaces (0.0.0.0)
+ Custom…
+
+
+
+
+ {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
+
+
+
setPortMode(value as PortMode)}>
+
+
+
+
+ Random (changes on restart)
+ Fixed…
+
+
+
+
+ {portMode === "fixed" && (
+
+
+
) => 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"}
+
+
+ {
+ loadStatus().catch((e) => {
+ setError(getErrorMessage(e));
+ });
+ }}
+ disabled={loading || saving}
+ >
+ Refresh
+
+ {
+ handleApply().catch((e) => {
+ setError(getErrorMessage(e));
+ });
+ }}
+ disabled={loading || saving}
+ >
+ {saving ? "Applying…" : "Apply"}
+
+
+
+
+ {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;