Files
Moltbot/src/cli/daemon-cli/shared.ts
Aviral b8c8130efe fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11448)
* fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11329)

When gateway.bind=lan, the HTTP server correctly binds to 0.0.0.0
(all interfaces), but WebSocket connection URLs, probe targets, and
Control UI links were hardcoded to 127.0.0.1. This caused CLI commands
and status probes to show localhost-only URLs even in LAN mode, and
made onboarding display misleading connection info.

- Add pickPrimaryLanIPv4() to gateway/net.ts to detect the machine's
  primary LAN IPv4 address (prefers en0/eth0, falls back to any
  external interface)
- Update pickProbeHostForBind() to use LAN IP when bind=lan
- Update buildGatewayConnectionDetails() to use LAN IP and report
  "local lan <ip>" as the URL source
- Update resolveControlUiLinks() to return LAN-accessible URLs
- Update probe note in status.gather.ts to reflect new behavior
- Add tests for pickPrimaryLanIPv4 and bind=lan URL resolution

Closes #11329

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: move vi.restoreAllMocks to afterEach in pickPrimaryLanIPv4

Per review feedback: avoid calling vi.restoreAllMocks() inside
individual tests as it restores all spies globally and can cause
ordering issues. Use afterEach in the describe block instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Changelog: note LAN bind URLs fix (#11448) (thanks @AnonO6)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-07 19:16:51 -06:00

220 lines
6.0 KiB
TypeScript

import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../../daemon/constants.js";
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
import { pickPrimaryLanIPv4 } from "../../gateway/net.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { formatCliCommand } from "../command-format.js";
export function parsePort(raw: unknown): number | null {
if (raw === undefined || raw === null) {
return null;
}
const value =
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "bigint"
? raw.toString()
: null;
if (value === null) {
return null;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
export function parsePortFromArgs(programArguments: string[] | undefined): number | null {
if (!programArguments?.length) {
return null;
}
for (let i = 0; i < programArguments.length; i += 1) {
const arg = programArguments[i];
if (arg === "--port") {
const next = programArguments[i + 1];
const parsed = parsePort(next);
if (parsed) {
return parsed;
}
}
if (arg?.startsWith("--port=")) {
const parsed = parsePort(arg.split("=", 2)[1]);
if (parsed) {
return parsed;
}
}
}
return null;
}
export function pickProbeHostForBind(
bindMode: string,
tailnetIPv4: string | undefined,
customBindHost?: string,
) {
if (bindMode === "custom" && customBindHost?.trim()) {
return customBindHost.trim();
}
if (bindMode === "tailnet") {
return tailnetIPv4 ?? "127.0.0.1";
}
if (bindMode === "lan") {
return pickPrimaryLanIPv4() ?? "127.0.0.1";
}
return "127.0.0.1";
}
const SAFE_DAEMON_ENV_KEYS = [
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_NIX_MODE",
];
export function filterDaemonEnv(env: Record<string, string> | undefined): Record<string, string> {
if (!env) {
return {};
}
const filtered: Record<string, string> = {};
for (const key of SAFE_DAEMON_ENV_KEYS) {
const value = env[key];
if (!value?.trim()) {
continue;
}
filtered[key] = value.trim();
}
return filtered;
}
export function safeDaemonEnv(env: Record<string, string> | undefined): string[] {
const filtered = filterDaemonEnv(env);
return Object.entries(filtered).map(([key, value]) => `${key}=${value}`);
}
export function normalizeListenerAddress(raw: string): string {
let value = raw.trim();
if (!value) {
return value;
}
value = value.replace(/^TCP\s+/i, "");
value = value.replace(/\s+\(LISTEN\)\s*$/i, "");
return value.trim();
}
export function formatRuntimeStatus(
runtime:
| {
status?: string;
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
}
| undefined,
) {
if (!runtime) {
return null;
}
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) {
details.push(`pid ${runtime.pid}`);
}
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
if (runtime.subState) {
details.push(`sub ${runtime.subState}`);
}
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);
}
if (runtime.lastRunResult) {
details.push(`last run ${runtime.lastRunResult}`);
}
if (runtime.lastRunTime) {
details.push(`last run time ${runtime.lastRunTime}`);
}
if (runtime.detail) {
details.push(runtime.detail);
}
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
}
export function renderRuntimeHints(
runtime: { missingUnit?: boolean; status?: string } | undefined,
env: NodeJS.ProcessEnv = process.env,
): string[] {
if (!runtime) {
return [];
}
const hints: string[] = [];
const fileLog = (() => {
try {
return getResolvedLoggerSettings().file;
} catch {
return null;
}
})();
if (runtime.missingUnit) {
hints.push(`Service not installed. Run: ${formatCliCommand("openclaw gateway install", env)}`);
if (fileLog) {
hints.push(`File logs: ${fileLog}`);
}
return hints;
}
if (runtime.status === "stopped") {
if (fileLog) {
hints.push(`File logs: ${fileLog}`);
}
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (process.platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE);
hints.push(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`);
} else if (process.platform === "win32") {
const task = resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
}
}
return hints;
}
export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] {
const base = [
formatCliCommand("openclaw gateway install", env),
formatCliCommand("openclaw gateway", env),
];
const profile = env.OPENCLAW_PROFILE;
switch (process.platform) {
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [...base, `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${label}.plist`];
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [...base, `systemctl --user start ${unit}.service`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [...base, `schtasks /Run /TN "${task}"`];
}
default:
return base;
}
}