Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ SERVER_PORT="${SERVER_PORT:-3000}"
SERVER_HOST="${SERVER_HOST:-localhost}"
STARTUP_TIMEOUT="${STARTUP_TIMEOUT:-30}"
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-10}"
AUTH_TOKEN="smoke-test-token-$(date +%s)"
# When set to "1", strip npm-shrinkwrap.json from the package before installing.
# This simulates package managers like `bun x` that ignore shrinkwrap, catching
# dependency resolution issues that the lockfile would otherwise mask.
Expand Down Expand Up @@ -148,7 +149,7 @@ log_info "✅ mux api subcommand works"

# Start the server in background
log_info "Starting mux server on $SERVER_HOST:$SERVER_PORT..."
node_modules/.bin/mux server --host "$SERVER_HOST" --port "$SERVER_PORT" >server.log 2>&1 &
node_modules/.bin/mux server --host "$SERVER_HOST" --port "$SERVER_PORT" --auth-token "$AUTH_TOKEN" >server.log 2>&1 &
SERVER_PID=$!

log_info "Server started with PID: $SERVER_PID"
Expand Down Expand Up @@ -238,7 +239,10 @@ const PROJECT_DIR = '$PROJECT_DIR';
async function runTests() {
// Test 1: HTTP oRPC client - create project
console.log('Testing oRPC project creation via HTTP...');
const httpLink = new RPCLink({ url: ORPC_URL });
const httpLink = new RPCLink({
url: ORPC_URL,
headers: { 'Authorization': 'Bearer ${AUTH_TOKEN}' }
});
const client = createORPCClient(httpLink);

const projectResult = await client.projects.create({ projectPath: PROJECT_DIR });
Expand All @@ -262,7 +266,7 @@ async function runTests() {
// Test 3: WebSocket connection
console.log('Testing oRPC WebSocket connection...');
await new Promise((resolve, reject) => {
const ws = new WebSocket(WS_URL);
const ws = new WebSocket(WS_URL, { headers: { 'Authorization': 'Bearer ${AUTH_TOKEN}' } });
const timeout = setTimeout(() => {
ws.close();
reject(new Error('WebSocket connection timed out'));
Expand Down
2 changes: 2 additions & 0 deletions src/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ describe("mux CLI", () => {
expect(result.stdout).toContain("--host");
expect(result.stdout).toContain("--port");
expect(result.stdout).toContain("--auth-token");
expect(result.stdout).toContain("--no-auth");
expect(result.stdout).toContain("--print-auth-token");
expect(result.stdout).toContain("--add-project");
});
});
Expand Down
128 changes: 66 additions & 62 deletions src/cli/server.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,46 @@
/**
* CLI entry point for the mux oRPC server.
* Uses createOrpcServer from ./orpcServer.ts for the actual server logic.
* Uses ServerService for server lifecycle management.
*/
import { Config } from "@/node/config";
import { ServiceContainer } from "@/node/services/serviceContainer";
import { ServerLockfile } from "@/node/services/serverLockfile";
import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths";
import { ServerLockfile } from "@/node/services/serverLockfile";
import type { BrowserWindow } from "electron";
import { Command } from "commander";
import { validateProjectPath } from "@/node/utils/pathUtils";
import { createOrpcServer } from "@/node/orpc/server";
import { VERSION } from "@/version";
import {
buildMuxMdnsServiceOptions,
MdnsAdvertiserService,
} from "@/node/services/mdnsAdvertiserService";
import * as os from "os";
import { getParseOptions } from "./argv";
import { resolveServerAuthToken } from "./serverAuthToken";

const program = new Command();
program
.name("mux server")
.description("HTTP/WebSocket ORPC server for mux")
.option("-h, --host <host>", "bind to specific host", "localhost")
.option("-p, --port <port>", "bind to specific port", "3000")
.option("--auth-token <token>", "optional bearer token for HTTP/WS auth")
.option("--auth-token <token>", "bearer token for HTTP/WS auth (default: auto-generated)")
.option("--no-auth", "disable authentication (server is open to anyone who can reach it)")
.option("--print-auth-token", "always print the auth token on startup")
.option("--ssh-host <host>", "SSH hostname/alias for editor deep links (e.g., devbox)")
.option("--add-project <path>", "add and open project at the specified path (idempotent)")
.parse(process.argv, getParseOptions());

const options = program.opts();
const HOST = options.host as string;
const PORT = Number.parseInt(String(options.port), 10);
const rawAuthToken = (options.authToken as string | undefined) ?? process.env.MUX_SERVER_AUTH_TOKEN;
const AUTH_TOKEN = rawAuthToken?.trim() ? rawAuthToken.trim() : undefined;
const resolved = resolveServerAuthToken({
noAuth: options.noAuth === true || options.auth === false,
cliToken: options.authToken as string | undefined,
envToken: process.env.MUX_SERVER_AUTH_TOKEN,
});
const ADD_PROJECT_PATH = options.addProject as string | undefined;
// SSH host for editor deep links (CLI flag > env var > config file, resolved later)
const CLI_SSH_HOST = options.sshHost as string | undefined;

// Track the launch project path for initial navigation
let launchProjectPath: string | null = null;

function isLoopbackHost(host: string): boolean {
const normalized = host.trim().toLowerCase();

// IPv4 loopback range (RFC 1122): 127.0.0.0/8
if (normalized.startsWith("127.")) {
return true;
}

return normalized === "localhost" || normalized === "::1";
}

// Minimal BrowserWindow stub for services that expect one
const mockWindow: BrowserWindow = {
isDestroyed: () => false,
Expand All @@ -75,9 +64,13 @@ const mockWindow: BrowserWindow = {

migrateLegacyMuxHome();

// Check for existing server (Electron or another mux server instance)
const lockfile = new ServerLockfile(getMuxHome());
const existing = await lockfile.read();
// Early lockfile check: detect an existing server BEFORE initializing services.
// serviceContainer.initialize() resumes queued/running tasks (via TaskService),
// so we must fail fast here to avoid orphaned side effects when another server
// already holds the lock. ServerService.startServer() re-checks as defense-in-depth.
const muxHome = getMuxHome();
const earlyLockfile = new ServerLockfile(muxHome);
const existing = await earlyLockfile.read();
if (existing) {
console.error(`Error: mux API server is already running at ${existing.baseUrl}`);
console.error(`Use 'mux api' commands to interact with the running instance.`);
Expand All @@ -102,45 +95,63 @@ const mockWindow: BrowserWindow = {

const context = serviceContainer.toORPCContext();

const mdnsAdvertiser = new MdnsAdvertiserService();
const server = await createOrpcServer({
// Start server via ServerService (handles lockfile, mDNS, network URLs)
const serverInfo = await serviceContainer.serverService.startServer({
muxHome: serviceContainer.config.rootDir,
context,
host: HOST,
port: PORT,
authToken: AUTH_TOKEN,
context,
authToken: resolved.token,
serveStatic: true,
});

// Server is now listening - clear the startup keepalive since httpServer keeps the loop alive
clearInterval(startupKeepalive);

// Acquire lockfile so other instances know we're running
await lockfile.acquire(server.baseUrl, AUTH_TOKEN ?? "");

const mdnsAdvertisementEnabled = config.getMdnsAdvertisementEnabled();
if (mdnsAdvertisementEnabled !== false && !isLoopbackHost(HOST)) {
const instanceName = config.getMdnsServiceName() ?? `mux-${os.hostname()}`;
const serviceOptions = buildMuxMdnsServiceOptions({
bindHost: HOST,
port: server.port,
instanceName,
version: VERSION.git_describe,
authRequired: AUTH_TOKEN?.trim().length ? true : false,
});

try {
await mdnsAdvertiser.start(serviceOptions);
} catch (err) {
console.warn("Failed to advertise mux API server via mDNS:", err);
// --- Startup output ---
console.log(`\nmux server v${VERSION.git_describe}`);
console.log(` URL: ${serverInfo.baseUrl}`);
if (serverInfo.networkBaseUrls.length > 0) {
for (const url of serverInfo.networkBaseUrls) {
console.log(` LAN: ${url}`);
}
} else if (mdnsAdvertisementEnabled === true && isLoopbackHost(HOST)) {
}
console.log(` Docs: ${serverInfo.baseUrl}/api/docs`);

if (resolved.mode === "disabled") {
console.warn(
"mDNS advertisement requested, but the API server is loopback-only. " +
"Set --host 0.0.0.0 (or a LAN IP) to enable LAN discovery."
"\nWARNING: Authentication is DISABLED (--no-auth). The server is open to anyone who can reach it."
);
}
} else {
console.log(`\n Auth: enabled (token source: ${resolved.source})`);

// Print token when explicitly requested or when network-accessible
const showToken =
options.printAuthToken === true ||
serverInfo.networkBaseUrls.length > 0 ||
resolved.source === "generated";
if (showToken) {
// Use a LAN-reachable URL for remote connection instructions when available,
// since baseUrl is loopback (127.0.0.1) even when binding to 0.0.0.0.
const remoteUrl =
serverInfo.networkBaseUrls.length > 0 ? serverInfo.networkBaseUrls[0] : serverInfo.baseUrl;
// Shell-quote the token to handle metacharacters ($, &, spaces, etc.)
const shellToken = `'${resolved.token.replace(/'/g, "'\\''")}'`;
const urlToken = encodeURIComponent(resolved.token);

console.log(`\n # Connect from another machine:`);
console.log(` export MUX_SERVER_URL=${remoteUrl}`);
console.log(` export MUX_SERVER_AUTH_TOKEN=${shellToken}`);
console.log(`\n # Open in browser:`);
console.log(` ${remoteUrl}/?token=${urlToken}`);
}

console.log(`Server is running on ${server.baseUrl}`);
const lockfilePath = serviceContainer.serverService.getLockfilePath();
if (lockfilePath) {
console.log(`\n Token stored in: ${lockfilePath}`);
}
}
console.log(""); // blank line

// Cleanup on shutdown
let cleanupInProgress = false;
Expand All @@ -157,21 +168,14 @@ const mockWindow: BrowserWindow = {
}, 5000);

try {
// Close all PTY sessions first (these are the "sub-processes" nodemon sees)
// Close all PTY sessions first
serviceContainer.terminalService.closeAllSessions();

// Dispose background processes
await serviceContainer.dispose();

// Release lockfile and close server
try {
await mdnsAdvertiser.stop();
} catch (err) {
console.warn("Failed to stop mDNS advertiser:", err);
}

await lockfile.release();
await server.close();
// Stop server (releases lockfile, stops mDNS, closes HTTP server)
await serviceContainer.serverService.stopServer();

clearTimeout(forceExitTimer);
process.exit(0);
Expand Down
99 changes: 99 additions & 0 deletions src/cli/serverAuthToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import { resolveServerAuthToken } from "./serverAuthToken";

describe("resolveServerAuthToken", () => {
test("returns disabled mode when noAuth is true regardless of other values", () => {
expect(
resolveServerAuthToken({
noAuth: true,
cliToken: "abc",
envToken: "env-token",
})
).toEqual({ mode: "disabled", token: "" });
});

test("uses cliToken when provided", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: "abc",
envToken: "env-token",
})
).toEqual({ mode: "enabled", token: "abc", source: "cli" });
});

test("trims cliToken", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: " abc ",
envToken: "env-token",
})
).toEqual({ mode: "enabled", token: "abc", source: "cli" });
});

test("falls through to envToken when cliToken is empty", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: "",
envToken: "env-token",
})
).toEqual({ mode: "enabled", token: "env-token", source: "env" });
});

test("falls through to envToken when cliToken is whitespace only", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: " ",
envToken: "env-token",
})
).toEqual({ mode: "enabled", token: "env-token", source: "env" });
});

test("uses envToken when cliToken is not provided", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: undefined,
envToken: "env-token",
})
).toEqual({ mode: "enabled", token: "env-token", source: "env" });
});

test("trims envToken", () => {
expect(
resolveServerAuthToken({
noAuth: false,
cliToken: undefined,
envToken: " env-token ",
})
).toEqual({ mode: "enabled", token: "env-token", source: "env" });
});

test("uses generated token when neither cliToken nor envToken is provided", () => {
const generated = resolveServerAuthToken({
noAuth: false,
cliToken: undefined,
envToken: undefined,
randomBytesFn: () => Buffer.from("a".repeat(32)),
});

expect(generated).toEqual({
mode: "enabled",
token: Buffer.from("a".repeat(32)).toString("hex"),
source: "generated",
});
});

test("noAuth still wins when cliToken is set", () => {
expect(
resolveServerAuthToken({
noAuth: true,
cliToken: "abc",
envToken: undefined,
})
).toEqual({ mode: "disabled", token: "" });
});
});
35 changes: 35 additions & 0 deletions src/cli/serverAuthToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { randomBytes } from "crypto";

export type ResolvedAuthToken =
| { mode: "disabled"; token: "" }
| { mode: "enabled"; token: string; source: "cli" | "env" | "generated" };

/**
* Resolve the auth token for `mux server` from CLI flags, env vars, or generate one.
*
* Precedence: --no-auth > --auth-token > MUX_SERVER_AUTH_TOKEN > auto-generated.
*/
export function resolveServerAuthToken(opts: {
noAuth: boolean;
cliToken: string | undefined;
envToken: string | undefined;
/** Injectable for deterministic testing. */
randomBytesFn?: (n: number) => Buffer;
}): ResolvedAuthToken {
if (opts.noAuth) {
return { mode: "disabled", token: "" };
}

const cli = opts.cliToken?.trim();
if (cli) {
return { mode: "enabled", token: cli, source: "cli" };
}

const env = opts.envToken?.trim();
if (env) {
return { mode: "enabled", token: env, source: "env" };
}

const gen = (opts.randomBytesFn ?? randomBytes)(32).toString("hex");
return { mode: "enabled", token: gen, source: "generated" };
}
8 changes: 8 additions & 0 deletions src/node/services/serverService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,4 +335,12 @@ export class ServerService {
isServerRunning(): boolean {
return this.server !== null;
}

/**
* Get the path to the server lockfile (for displaying to users).
* Returns null if no server lockfile has been acquired yet.
*/
getLockfilePath(): string | null {
return this.lockfile?.getLockPath() ?? null;
}
}
Loading