import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js"; import { CONFIG_PATH, loadConfig, readConfigFileSnapshot, resolveStateDir, resolveGatewayPort, } from "../../config/config.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { resolveGatewayAuth } from "../../gateway/auth.js"; import { startGatewayServer } from "../../gateway/server.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { inheritOptionFromParent } from "../command-options.js"; import { forceFreePortAndWait, waitForPortBindable } from "../ports.js"; import { ensureDevGatewayConfig } from "./dev.js"; import { runGatewayLoop } from "./run-loop.js"; import { describeUnknownError, extractGatewayMiskeys, maybeExplainGatewayServiceStop, parsePort, toOptionString, } from "./shared.js"; type GatewayRunOpts = { port?: unknown; bind?: unknown; token?: unknown; auth?: unknown; password?: unknown; tailscale?: unknown; tailscaleResetOnExit?: boolean; allowUnconfigured?: boolean; force?: boolean; verbose?: boolean; 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", "tailscale", "wsLog", "rawStreamPath", ] as const; const GATEWAY_RUN_BOOLEAN_KEYS = [ "tailscaleResetOnExit", "allowUnconfigured", "dev", "reset", "force", "verbose", "claudeCliLogs", "compact", "rawStream", ] as const; const GATEWAY_AUTH_MODES: readonly GatewayAuthMode[] = [ "none", "token", "password", "trusted-proxy", ]; const GATEWAY_TAILSCALE_MODES: readonly GatewayTailscaleMode[] = ["off", "serve", "funnel"]; 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 T[]): string { return modes.map((mode) => `"${mode}"`).join("|"); } function formatModeErrorList(modes: readonly T[]): 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]}`; } 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; } async function runGatewayCommand(opts: GatewayRunOpts) { const isDevProfile = process.env.OPENCLAW_PROFILE?.trim().toLowerCase() === "dev"; const devMode = Boolean(opts.dev) || isDevProfile; if (opts.reset && !devMode) { defaultRuntime.error("Use --reset with --dev."); defaultRuntime.exit(1); return; } setConsoleTimestampPrefix(true); setVerbose(Boolean(opts.verbose)); if (opts.claudeCliLogs) { setConsoleSubsystemFilter(["agent/claude-cli"]); process.env.OPENCLAW_CLAUDE_CLI_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", "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; } if (devMode) { await ensureDevGatewayConfig({ reset: Boolean(opts.reset) }); } const cfg = loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { defaultRuntime.error("Invalid port"); defaultRuntime.exit(1); } const port = portOverride ?? resolveGatewayPort(cfg); if (!Number.isFinite(port) || port <= 0) { defaultRuntime.error("Invalid port"); defaultRuntime.exit(1); } const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; const bind = bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" || bindRaw === "tailnet" ? bindRaw : null; if (!bind) { defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); defaultRuntime.exit(1); return; } if (opts.force) { try { 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 = bind === "loopback" ? "127.0.0.1" : bind === "lan" ? "0.0.0.0" : bind === "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(`Force: ${String(err)}`); 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; } const passwordRaw = toOptionString(opts.password); const tokenRaw = toOptionString(opts.token); const snapshot = await readConfigFileSnapshot().catch(() => null); const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH); const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl"); const mode = cfg.gateway?.mode; if (!opts.allowUnconfigured && mode !== "local") { if (!configExists) { defaultRuntime.error( `Missing config. Run \`${formatCliCommand("openclaw setup")}\` or set gateway.mode=local (or pass --allow-unconfigured).`, ); } else { defaultRuntime.error( `Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`, ); defaultRuntime.error(`Config write audit: ${configAuditPath}`); } defaultRuntime.exit(1); return; } const miskeys = extractGatewayMiskeys(snapshot?.parsed); const authOverride = authMode || passwordRaw || tokenRaw || authModeRaw ? { ...(authMode ? { mode: authMode } : {}), ...(tokenRaw ? { token: tokenRaw } : {}), ...(passwordRaw ? { password: passwordRaw } : {}), } : undefined; const resolvedAuth = 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(1); 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.`, "Set gateway.auth.token/password (or OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD) or pass --token/--password.", ...authHints, ] .filter(Boolean) .join("\n"), ); defaultRuntime.exit(1); return; } const tailscaleOverride = tailscaleMode || opts.tailscaleResetOnExit ? { ...(tailscaleMode ? { mode: tailscaleMode } : {}), ...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}), } : undefined; try { await runGatewayLoop({ runtime: defaultRuntime, lockPort: port, start: async () => await startGatewayServer(port, { bind, auth: authOverride, tailscale: tailscaleOverride, }), }); } catch (err) { if ( err instanceof GatewayLockError || (err && typeof err === "object" && (err as { name?: string }).name === "GatewayLockError") ) { const errMessage = describeUnknownError(err); defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("openclaw gateway stop")}`, ); try { const diagnostics = await inspectPortUsage(port); if (diagnostics.status === "busy") { for (const line of formatPortDiagnostics(diagnostics)) { defaultRuntime.error(line); } } } catch { // ignore diagnostics failures } await maybeExplainGatewayServiceStop(); defaultRuntime.exit(1); return; } defaultRuntime.error(`Gateway failed to start: ${String(err)}`); defaultRuntime.exit(1); } } 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( "--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 gateway.mode=local in 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( "--claude-cli-logs", "Only show claude-cli logs in the console (includes stdout/stderr)", false, ) .option("--ws-log