import fs from "node:fs"; import { request } from "node:http"; import path from "node:path"; import type { Command } from "commander"; import type { ConfigFileSnapshot, GatewayAuthMode, GatewayBindMode, GatewayTailscaleMode, ReadConfigFileSnapshotWithPluginMetadataResult, } from "../../config/config.js"; import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { defaultGatewayBindMode, isContainerEnvironment, resolveGatewayBindHost, } from "../../gateway/net.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; import { isTruthyEnvValue } from "../../infra/env.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; import { formatCliCommand } from "../command-format.js"; import { inheritOptionFromParent } from "../command-options.js"; import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js"; import { withProgress } from "../progress.js"; import { parsePort } from "../shared/parse-port.js"; import { installQaParentWatchdog } from "./qa-parent-watchdog.js"; import { runGatewayLoop } from "./run-loop.js"; type GatewayRunOpts = { port?: unknown; bind?: unknown; token?: unknown; auth?: unknown; password?: unknown; passwordFile?: unknown; tailscale?: unknown; tailscaleResetOnExit?: boolean; allowUnconfigured?: boolean; force?: boolean; verbose?: boolean; cliBackendLogs?: boolean; /** @deprecated Use cliBackendLogs. */ claudeCliLogs?: boolean; wsLog?: unknown; compact?: boolean; rawStream?: boolean; rawStreamPath?: unknown; dev?: boolean; reset?: boolean; }; const gatewayLog = createSubsystemLogger("gateway"); const GATEWAY_RUN_VALUE_KEYS = [ "port", "bind", "token", "auth", "password", "passwordFile", "tailscale", "wsLog", "rawStreamPath", ] as const; const GATEWAY_RUN_BOOLEAN_KEYS = [ "tailscaleResetOnExit", "allowUnconfigured", "dev", "reset", "force", "verbose", "cliBackendLogs", "claudeCliLogs", "compact", "rawStream", ] as const; const SUPERVISED_GATEWAY_LOCK_RETRY_MS = 5000; const SUPERVISED_GATEWAY_LOCK_RETRY_TIMEOUT_MS = 30_000; const SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS = 1000; type Awaitable = T | Promise; type GatewayRunLogger = Pick, "info" | "warn">; /** * EX_CONFIG (78) from sysexits.h — used for configuration errors so systemd * (via RestartPreventExitStatus=78) stops restarting instead of entering a * restart storm that can render low-resource hosts unresponsive. */ const EXIT_CONFIG_ERROR = 78; const GATEWAY_AUTH_MODES: readonly GatewayAuthMode[] = [ "none", "token", "password", "trusted-proxy", ]; const GATEWAY_TAILSCALE_MODES: readonly GatewayTailscaleMode[] = ["off", "serve", "funnel"]; const toOptionString = (value: unknown): string | undefined => { if (typeof value === "string") { return value; } if (typeof value === "number" || typeof value === "bigint") { return value.toString(); } return undefined; }; function extractGatewayMiskeys(parsed: unknown): { hasGatewayToken: boolean; hasRemoteToken: boolean; } { if (!parsed || typeof parsed !== "object") { return { hasGatewayToken: false, hasRemoteToken: false }; } const gateway = (parsed as Record).gateway; if (!gateway || typeof gateway !== "object") { return { hasGatewayToken: false, hasRemoteToken: false }; } const hasGatewayToken = "token" in (gateway as Record); const remote = (gateway as Record).remote; const hasRemoteToken = remote && typeof remote === "object" ? "token" in (remote as Record) : false; return { hasGatewayToken, hasRemoteToken }; } function createGatewayCliStartupTrace() { const enabled = isTruthyEnvValue(process.env.OPENCLAW_GATEWAY_STARTUP_TRACE); const started = performance.now(); let last = started; const emit = (name: string, durationMs: number, totalMs: number) => { if (enabled) { gatewayLog.info( `startup trace: ${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms`, ); } }; return { mark(name: string) { const now = performance.now(); emit(name, now - last, now - started); last = now; }, async measure(name: string, run: () => Awaitable): Promise { const before = performance.now(); try { return await withDiagnosticPhase(name, run); } finally { const now = performance.now(); emit(name, now - before, now - started); last = now; } }, }; } function warnInlinePasswordFlag() { defaultRuntime.error( "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", ); } async function resolveGatewayPasswordOption(opts: GatewayRunOpts): Promise { const direct = toOptionString(opts.password); const file = toOptionString(opts.passwordFile); if (direct && file) { throw new Error("Use either --password or --password-file."); } if (file) { const { readSecretFromFile } = await import("../../acp/secret-file.js"); return readSecretFromFile(file, "Gateway password"); } return direct; } function parseEnumOption( raw: string | undefined, allowed: readonly T[], ): T | null { if (!raw) { return null; } return (allowed as readonly string[]).includes(raw) ? (raw as T) : null; } function formatModeChoices(modes: readonly string[]): string { return modes.map((mode) => `"${mode}"`).join("|"); } function formatModeErrorList(modes: readonly string[]): string { const quoted = modes.map((mode) => `"${mode}"`); if (quoted.length === 0) { return ""; } if (quoted.length === 1) { return quoted[0]; } if (quoted.length === 2) { return `${quoted[0]} or ${quoted[1]}`; } return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`; } async function maybeLogPendingControlUiBuild(cfg: OpenClawConfig): Promise { if (cfg.gateway?.controlUi?.enabled === false) { return; } if (toOptionString(cfg.gateway?.controlUi?.root)) { return; } const { resolveControlUiRootSync } = await import("../../infra/control-ui-assets.js"); if ( resolveControlUiRootSync({ moduleUrl: import.meta.url, argv1: process.argv[1], cwd: process.cwd(), }) ) { return; } gatewayLog.info( "Control UI assets are missing; first startup may spend a few seconds building them before the gateway binds. `pnpm gateway:watch` does not rebuild Control UI assets, so rerun `pnpm ui:build` after UI changes or use `pnpm ui:dev` while developing the Control UI. For a full local dist, run `pnpm build && pnpm ui:build`.", ); } function getGatewayStartGuardErrors(params: { allowUnconfigured?: boolean; configExists: boolean; configAuditPath: string; mode: string | undefined; }): string[] { if (params.allowUnconfigured || params.mode === "local") { return []; } if (!params.configExists) { return [ `Missing config. Run \`${formatCliCommand("openclaw setup")}\` or set gateway.mode=local (or pass --allow-unconfigured).`, ]; } if (params.mode === undefined) { return [ [ "Gateway start blocked: existing config is missing gateway.mode.", "Treat this as suspicious or clobbered config.", `Re-run \`${formatCliCommand("openclaw onboard --mode local")}\` or \`${formatCliCommand("openclaw setup")}\`, set gateway.mode=local manually, or pass --allow-unconfigured.`, ].join(" "), `Config write audit: ${params.configAuditPath}`, ]; } return [ `Gateway start blocked: set gateway.mode=local (current: ${params.mode}) or pass --allow-unconfigured.`, `Config write audit: ${params.configAuditPath}`, ]; } async function readGatewayStartupConfig(params: { startupTrace: ReturnType; }): Promise<{ cfg: OpenClawConfig; snapshot: ConfigFileSnapshot | null; startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult; }> { const { readConfigFileSnapshotWithPluginMetadata } = await import("../../config/config.js"); const snapshotRead: ReadConfigFileSnapshotWithPluginMetadataResult | null = await params.startupTrace.measure("cli.config-snapshot", () => readConfigFileSnapshotWithPluginMetadata().catch(() => null), ); const snapshot: ConfigFileSnapshot | null = snapshotRead?.snapshot ?? null; const cfg = snapshot?.config ?? {}; return { cfg, snapshot, ...(snapshotRead ? { startupConfigSnapshotRead: snapshotRead } : {}), }; } function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts { const resolved: GatewayRunOpts = { ...opts }; for (const key of GATEWAY_RUN_VALUE_KEYS) { const inherited = inheritOptionFromParent(command, key); if (key === "wsLog") { // wsLog has a child default ("auto"), so prefer inherited parent CLI value when present. resolved[key] = inherited ?? resolved[key]; continue; } resolved[key] = resolved[key] ?? inherited; } for (const key of GATEWAY_RUN_BOOLEAN_KEYS) { const inherited = inheritOptionFromParent(command, key); resolved[key] = Boolean(resolved[key] || inherited); } return resolved; } function isGatewayLockError(err: unknown): err is GatewayLockError { return ( err instanceof GatewayLockError || (!!err && typeof err === "object" && (err as { name?: string }).name === "GatewayLockError") ); } function isGatewayAlreadyRunningLockError(err: unknown): boolean { if (!isGatewayLockError(err) || typeof err.message !== "string") { return false; } return ( err.message.includes("gateway already running") || err.message.includes("another gateway instance is already listening") ); } function isHealthyGatewayLockError(err: unknown): boolean { return isGatewayAlreadyRunningLockError(err); } function resolveGatewayLockErrorExitCode( err: unknown, supervisor: RespawnSupervisor | null, ): number { if (supervisor === "systemd" && isGatewayAlreadyRunningLockError(err)) { return EXIT_CONFIG_ERROR; } return isHealthyGatewayLockError(err) ? 0 : 1; } function normalizeGatewayHealthProbeHost(host: string): string { if (host === "0.0.0.0" || host === "::") { return "127.0.0.1"; } return host; } async function probeGatewayHealthz(params: { host: string; port: number; timeoutMs?: number; }): Promise { const timeoutMs = params.timeoutMs ?? SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS; return await new Promise((resolve) => { const req = request( { hostname: normalizeGatewayHealthProbeHost(params.host), port: params.port, path: "/healthz", method: "GET", timeout: timeoutMs, }, (res) => { res.resume(); resolve(typeof res.statusCode === "number" && res.statusCode < 500); }, ); req.once("timeout", () => { req.destroy(); resolve(false); }); req.once("error", () => { resolve(false); }); req.end(); }); } async function runGatewayLoopWithSupervisedLockRecovery(params: { startLoop: () => Promise; supervisor: RespawnSupervisor | null; port: number; healthHost: string; log: GatewayRunLogger; now?: () => number; sleep?: (ms: number) => Promise; probeHealth?: (params: { host: string; port: number }) => Promise; retryMs?: number; timeoutMs?: number; }) { const supervisor = params.supervisor; if (!supervisor) { await params.startLoop(); return; } const now = params.now ?? Date.now; const sleep = params.sleep ?? (async (ms: number) => await new Promise((resolve) => setTimeout(resolve, ms))); const probeHealth = params.probeHealth ?? ((probeParams) => probeGatewayHealthz(probeParams)); const retryMs = params.retryMs ?? SUPERVISED_GATEWAY_LOCK_RETRY_MS; const timeoutMs = params.timeoutMs ?? SUPERVISED_GATEWAY_LOCK_RETRY_TIMEOUT_MS; const startedAt = now(); for (;;) { try { await params.startLoop(); return; } catch (err) { if (!isGatewayAlreadyRunningLockError(err)) { throw err; } if (await probeHealth({ host: params.healthHost, port: params.port })) { if (supervisor === "systemd") { throw new GatewayLockError( "gateway already running under systemd; existing gateway is healthy, exiting with code 78 to prevent a systemd Restart=always loop", err, ); } params.log.info( `gateway already running under ${supervisor}; existing gateway is healthy, leaving it in control`, ); return; } const elapsedMs = now() - startedAt; if (elapsedMs >= timeoutMs) { throw new GatewayLockError( `gateway already running under ${supervisor}; existing gateway did not become healthy after ${timeoutMs}ms`, err, ); } const waitMs = Math.min(retryMs, Math.max(0, timeoutMs - elapsedMs)); params.log.warn( `gateway already running under ${supervisor}; waiting ${waitMs}ms before retrying startup`, ); await sleep(waitMs); } } } async function maybeWriteGatewayStartupFailureBundle(err: unknown): Promise { const { writeDiagnosticStabilityBundleForFailureSync } = await import("../../logging/diagnostic-stability-bundle.js"); const result = writeDiagnosticStabilityBundleForFailureSync("gateway.startup_failed", err); if ("message" in result) { gatewayLog.warn(result.message); } } async function runGatewayCommand(opts: GatewayRunOpts) { installQaParentWatchdog(); const isDevProfile = normalizeOptionalLowercaseString(process.env.OPENCLAW_PROFILE) === "dev"; const devMode = Boolean(opts.dev) || isDevProfile; if (opts.reset && !devMode) { defaultRuntime.error("Use --reset with --dev."); defaultRuntime.exit(1); return; } setVerbose(Boolean(opts.verbose)); if (opts.cliBackendLogs || opts.claudeCliLogs) { setConsoleSubsystemFilter(["agent/cli-backend"]); process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT = "1"; } const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as string | undefined; const wsLogStyle: GatewayWsLogStyle = wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto"; if ( wsLogRaw !== undefined && wsLogRaw !== "auto" && wsLogRaw !== "compact" && wsLogRaw !== "full" ) { defaultRuntime.error('Invalid --ws-log. Use "auto", "full", or "compact".'); defaultRuntime.exit(1); } setGatewayWsLogStyle(wsLogStyle); if (opts.rawStream) { process.env.OPENCLAW_RAW_STREAM = "1"; } const rawStreamPath = toOptionString(opts.rawStreamPath); if (rawStreamPath) { process.env.OPENCLAW_RAW_STREAM_PATH = rawStreamPath; } const startupTrace = createGatewayCliStartupTrace(); // The heaviest part of gateway startup is loading the server module tree // (channels, plugins, HTTP stack, etc.). Show a spinner so the user sees // progress instead of a silent 15-20 s pause (especially on Windows/NTFS). const { startGatewayServer } = await startupTrace.measure("cli.server-import", () => withProgress( { label: "Loading gateway modules…", indeterminate: true }, async () => import("../../gateway/server.js"), ), ); setConsoleTimestampPrefix(true); if (devMode) { const { ensureDevGatewayConfig } = await import("./dev.js"); await startupTrace.measure("cli.dev-config", () => ensureDevGatewayConfig({ reset: Boolean(opts.reset) }), ); } gatewayLog.info("loading configuration…"); const { cfg, snapshot, startupConfigSnapshotRead } = await readGatewayStartupConfig({ startupTrace, }); void maybeLogPendingControlUiBuild(cfg).catch((err) => { gatewayLog.warn(`Control UI asset check failed: ${String(err)}`); }); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { defaultRuntime.error(formatInvalidPortOption("--port")); defaultRuntime.exit(1); return; } const port = portOverride ?? resolveGatewayPort(cfg); if (!Number.isFinite(port) || port <= 0 || port > 65_535) { defaultRuntime.error(formatInvalidConfigPort("gateway.port")); defaultRuntime.exit(1); return; } const { formatFutureConfigActionBlock, resolveFutureConfigActionBlock } = await import("../../config/future-version-guard.js"); const futureStartupBlock = resolveFutureConfigActionBlock({ action: "start the gateway service", snapshot, }); if (futureStartupBlock && process.env.OPENCLAW_SERVICE_MARKER?.trim()) { defaultRuntime.error(formatFutureConfigActionBlock(futureStartupBlock)); defaultRuntime.exit(78); return; } const futureForceBlock = opts.force ? resolveFutureConfigActionBlock({ action: "force-kill gateway port listeners", snapshot, }) : null; if (futureForceBlock) { defaultRuntime.error(formatFutureConfigActionBlock(futureForceBlock)); defaultRuntime.exit(1); return; } // Only capture the *explicit* bind value here. The container-aware // default is deferred until after Tailscale mode is known (see below) // so that Tailscale's loopback constraint is respected. const VALID_BIND_MODES = new Set(["loopback", "lan", "auto", "custom", "tailnet"]); const bindExplicitRawStr = normalizeOptionalString( toOptionString(opts.bind) ?? cfg.gateway?.bind, ); if (bindExplicitRawStr !== undefined && !VALID_BIND_MODES.has(bindExplicitRawStr)) { defaultRuntime.error('Invalid --bind. Use "loopback", "lan", "tailnet", "auto", or "custom".'); defaultRuntime.exit(1); return; } const bindExplicitRaw = bindExplicitRawStr as GatewayBindMode | undefined; if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) { const { cleanStaleGatewayProcessesSync } = await import("../../infra/restart-stale-pids.js"); const stale = cleanStaleGatewayProcessesSync(port); if (stale.length > 0) { gatewayLog.info( `service-mode: cleared ${stale.length} stale gateway pid(s) before bind on port ${port}`, ); } } if (opts.force) { try { const { forceFreePortAndWait, waitForPortBindable } = await import("../ports.js"); const { killed, waitedMs, escalatedToSigkill } = await forceFreePortAndWait(port, { timeoutMs: 2000, intervalMs: 100, sigtermTimeoutMs: 700, }); if (killed.length === 0) { gatewayLog.info(`force: no listeners on port ${port}`); } else { for (const proc of killed) { gatewayLog.info( `force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`, ); } if (escalatedToSigkill) { gatewayLog.info(`force: escalated to SIGKILL while freeing port ${port}`); } if (waitedMs > 0) { gatewayLog.info(`force: waited ${waitedMs}ms for port ${port} to free`); } } // After killing, verify the port is actually bindable (handles TIME_WAIT). const bindProbeHost = bindExplicitRaw === "loopback" ? "127.0.0.1" : bindExplicitRaw === "lan" ? "0.0.0.0" : bindExplicitRaw === "custom" ? toOptionString(cfg.gateway?.customBindHost) : undefined; const bindWaitMs = await waitForPortBindable(port, { timeoutMs: 3000, intervalMs: 150, host: bindProbeHost, }); if (bindWaitMs > 0) { gatewayLog.info(`force: waited ${bindWaitMs}ms for port ${port} to become bindable`); } } catch (err) { defaultRuntime.error( `Could not free port ${port}: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} to inspect the listener.`, ); defaultRuntime.exit(1); return; } } if (opts.token) { const token = toOptionString(opts.token); if (token) { process.env.OPENCLAW_GATEWAY_TOKEN = token; } } const authModeRaw = toOptionString(opts.auth); const authMode = parseEnumOption(authModeRaw, GATEWAY_AUTH_MODES); if (authModeRaw && !authMode) { defaultRuntime.error(`Invalid --auth. Use ${formatModeErrorList(GATEWAY_AUTH_MODES)}.`); defaultRuntime.exit(1); return; } const tailscaleRaw = toOptionString(opts.tailscale); const tailscaleMode = parseEnumOption(tailscaleRaw, GATEWAY_TAILSCALE_MODES); if (tailscaleRaw && !tailscaleMode) { defaultRuntime.error( `Invalid --tailscale. Use ${formatModeErrorList(GATEWAY_TAILSCALE_MODES)}.`, ); defaultRuntime.exit(1); return; } // Now that Tailscale mode is known, compute the effective bind mode. const effectiveTailscaleMode = tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off"; const bind = (bindExplicitRaw ?? defaultGatewayBindMode(effectiveTailscaleMode)) as | "loopback" | "lan" | "auto" | "custom" | "tailnet"; let passwordRaw: string | undefined; try { passwordRaw = await resolveGatewayPasswordOption(opts); } catch (err) { defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); return; } if (toOptionString(opts.password)) { warnInlinePasswordFlag(); } const tokenRaw = toOptionString(opts.token); gatewayLog.info("resolving authentication…"); const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH); const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl"); const effectiveCfg = snapshot?.valid ? snapshot.config : cfg; const mode = effectiveCfg.gateway?.mode; const guardErrors = getGatewayStartGuardErrors({ allowUnconfigured: opts.allowUnconfigured, configExists, configAuditPath, mode, }); if (guardErrors.length > 0) { for (const error of guardErrors) { defaultRuntime.error(error); } defaultRuntime.exit(EXIT_CONFIG_ERROR); return; } const miskeys = extractGatewayMiskeys(snapshot?.parsed); const authOverride = authMode || passwordRaw || tokenRaw || authModeRaw ? { ...(authMode ? { mode: authMode } : {}), ...(tokenRaw ? { token: tokenRaw } : {}), ...(passwordRaw ? { password: passwordRaw } : {}), } : undefined; const { resolveGatewayAuth } = await import("../../gateway/auth.js"); const resolvedAuth = await startupTrace.measure("cli.auth-resolve", () => resolveGatewayAuth({ authConfig: cfg.gateway?.auth, authOverride, env: process.env, tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", }), ); const resolvedAuthMode = resolvedAuth.mode; const tokenValue = resolvedAuth.token; const passwordValue = resolvedAuth.password; const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0; const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0; const tokenConfigured = hasToken || hasConfiguredSecretInput( authOverride?.token ?? cfg.gateway?.auth?.token, cfg.secrets?.defaults, ); const passwordConfigured = hasPassword || hasConfiguredSecretInput( authOverride?.password ?? cfg.gateway?.auth?.password, cfg.secrets?.defaults, ); const hasSharedSecret = (resolvedAuthMode === "token" && tokenConfigured) || (resolvedAuthMode === "password" && passwordConfigured); const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); } if (miskeys.hasRemoteToken) { authHints.push( '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', ); } if (resolvedAuthMode === "password" && !passwordConfigured) { defaultRuntime.error( [ "Gateway auth is set to password, but no password is configured.", "Set gateway.auth.password (or OPENCLAW_GATEWAY_PASSWORD), or pass --password.", ...authHints, ] .filter(Boolean) .join("\n"), ); defaultRuntime.exit(EXIT_CONFIG_ERROR); return; } if (resolvedAuthMode === "none") { gatewayLog.warn( "Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.", ); } if ( bind !== "loopback" && !hasSharedSecret && !canBootstrapToken && resolvedAuthMode !== "trusted-proxy" ) { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, ...(isContainerEnvironment() ? [ "Container environment detected \u2014 the gateway defaults to bind=auto (0.0.0.0) for port-forwarding compatibility.", "Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, or pass --token/--password to start with auth.", ] : [ "Set gateway.auth.token/password (or OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD) or pass --token/--password.", ]), ...authHints, ] .filter(Boolean) .join("\n"), ); defaultRuntime.exit(EXIT_CONFIG_ERROR); return; } const tailscaleOverride = tailscaleMode || opts.tailscaleResetOnExit ? { ...(tailscaleMode ? { mode: tailscaleMode } : {}), ...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}), } : undefined; gatewayLog.info("starting..."); startupTrace.mark("cli.gateway-loop"); const healthHost = await resolveGatewayBindHost(bind, cfg.gateway?.customBindHost); let startupConfigSnapshotReadForNextStart = startupConfigSnapshotRead; const startLoop = async () => await runGatewayLoop({ runtime: defaultRuntime, lockPort: port, healthHost, start: async ({ startupStartedAt } = {}) => { const startupConfigSnapshotReadForThisStart = startupConfigSnapshotReadForNextStart; startupConfigSnapshotReadForNextStart = undefined; return await startGatewayServer(port, { bind, auth: authOverride, tailscale: tailscaleOverride, startupStartedAt, ...(startupConfigSnapshotReadForThisStart ? { startupConfigSnapshotRead: startupConfigSnapshotReadForThisStart } : {}), }); }, }); const { detectRespawnSupervisor } = await import("../../infra/supervisor-markers.js"); const supervisor = detectRespawnSupervisor(process.env); try { await runGatewayLoopWithSupervisedLockRecovery({ startLoop, supervisor, port, healthHost, log: gatewayLog, }); } catch (err) { if (isGatewayLockError(err)) { const errMessage = formatErrorMessage(err); defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("openclaw gateway stop")}`, ); try { const { formatPortDiagnostics, inspectPortUsage } = await import("../../infra/ports.js"); const diagnostics = await inspectPortUsage(port); if (diagnostics.status === "busy") { for (const line of formatPortDiagnostics(diagnostics)) { defaultRuntime.error(line); } } } catch { // ignore diagnostics failures } const { maybeExplainGatewayServiceStop } = await import("./shared.js"); await maybeExplainGatewayServiceStop(); defaultRuntime.exit(resolveGatewayLockErrorExitCode(err, supervisor)); return; } await maybeWriteGatewayStartupFailureBundle(err); defaultRuntime.error( `Gateway failed to start: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} for diagnostics.`, ); defaultRuntime.exit(1); } } export const __testing = { normalizeGatewayHealthProbeHost, resolveGatewayLockErrorExitCode, runGatewayLoopWithSupervisedLockRecovery, }; export function addGatewayRunCommand(cmd: Command): Command { return cmd .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", 'Bind mode ("loopback"|"lan"|"tailnet"|"auto"|"custom"). Defaults to config gateway.bind (or loopback).', ) .option( "--token ", "Shared token required in connect.params.auth.token (default: OPENCLAW_GATEWAY_TOKEN env if set)", ) .option("--auth ", `Gateway auth mode (${formatModeChoices(GATEWAY_AUTH_MODES)})`) .option("--password ", "Password for auth mode=password") .option("--password-file ", "Read gateway password from file") .option( "--tailscale ", `Tailscale exposure mode (${formatModeChoices(GATEWAY_TAILSCALE_MODES)})`, ) .option( "--tailscale-reset-on-exit", "Reset Tailscale serve/funnel configuration on shutdown", false, ) .option( "--allow-unconfigured", "Allow gateway start without enforcing gateway.mode=local in config (does not repair config)", false, ) .option("--dev", "Create a dev config + workspace if missing (no BOOTSTRAP.md)", false) .option( "--reset", "Reset dev config + credentials + sessions + workspace (requires --dev)", false, ) .option("--force", "Kill any existing listener on the target port before starting", false) .option("--verbose", "Verbose logging to stdout/stderr", false) .option( "--cli-backend-logs", "Only show CLI backend logs in the console (includes stdout/stderr)", false, ) .option("--claude-cli-logs", "Deprecated alias for --cli-backend-logs", false) .option("--ws-log