import fs from "node:fs"; import type { Command } from "commander"; import { CONFIG_PATH_CLAWDBOT, loadConfig, readConfigFileSnapshot, resolveGatewayPort, } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, } from "../daemon/constants.js"; import { resolveGatewayService } from "../daemon/service.js"; import { callGateway } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; import { type GatewayWsLogStyle, 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 { createSubsystemLogger } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; import { forceFreePortAndWait } from "./ports.js"; import { withProgress } from "./progress.js"; type GatewayRpcOpts = { url?: string; token?: string; password?: string; timeout?: string; expectFinal?: boolean; }; const gatewayLog = createSubsystemLogger("gateway"); type GatewayRunSignalAction = "stop" | "restart"; 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; } function describeUnknownError(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; if (typeof err === "number" || typeof err === "bigint") return err.toString(); if (typeof err === "boolean") return err ? "true" : "false"; if (err && typeof err === "object") { if ("message" in err && typeof err.message === "string") { return err.message; } try { return JSON.stringify(err); } catch { return "Unknown error"; } } return "Unknown error"; } 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 renderGatewayServiceStopHints(): string[] { switch (process.platform) { case "darwin": return [ "Tip: clawdbot daemon stop", `Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, ]; case "linux": return [ "Tip: clawdbot daemon stop", `Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, ]; case "win32": return [ "Tip: clawdbot daemon stop", `Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`, ]; default: return ["Tip: clawdbot daemon stop"]; } } async function maybeExplainGatewayServiceStop() { const service = resolveGatewayService(); let loaded: boolean | null = null; try { loaded = await service.isLoaded({ env: process.env }); } catch { loaded = null; } if (loaded === false) return; defaultRuntime.error( loaded ? `Gateway service appears ${service.loadedText}. Stop it first.` : "Gateway service status unknown; if supervised, stop it first.", ); for (const hint of renderGatewayServiceStopHints()) { defaultRuntime.error(hint); } } async function runGatewayLoop(params: { start: () => Promise>>; runtime: typeof defaultRuntime; }) { let server: Awaited> | null = null; let shuttingDown = false; let restartResolver: (() => void) | null = null; const cleanupSignals = () => { process.removeListener("SIGTERM", onSigterm); process.removeListener("SIGINT", onSigint); process.removeListener("SIGUSR1", onSigusr1); }; const request = (action: GatewayRunSignalAction, signal: string) => { if (shuttingDown) { gatewayLog.info(`received ${signal} during shutdown; ignoring`); return; } shuttingDown = true; const isRestart = action === "restart"; gatewayLog.info( `received ${signal}; ${isRestart ? "restarting" : "shutting down"}`, ); const forceExitTimer = setTimeout(() => { gatewayLog.error("shutdown timed out; exiting without full cleanup"); cleanupSignals(); params.runtime.exit(0); }, 5000); void (async () => { try { await server?.close({ reason: isRestart ? "gateway restarting" : "gateway stopping", restartExpectedMs: isRestart ? 1500 : null, }); } catch (err) { gatewayLog.error(`shutdown error: ${String(err)}`); } finally { clearTimeout(forceExitTimer); server = null; if (isRestart) { shuttingDown = false; restartResolver?.(); } else { cleanupSignals(); params.runtime.exit(0); } } })(); }; const onSigterm = () => request("stop", "SIGTERM"); const onSigint = () => request("stop", "SIGINT"); const onSigusr1 = () => request("restart", "SIGUSR1"); process.on("SIGTERM", onSigterm); process.on("SIGINT", onSigint); process.on("SIGUSR1", onSigusr1); try { // Keep process alive; SIGUSR1 triggers an in-process restart (no supervisor required). // SIGTERM/SIGINT still exit after a graceful shutdown. // eslint-disable-next-line no-constant-condition while (true) { server = await params.start(); await new Promise((resolve) => { restartResolver = resolve; }); } } finally { cleanupSignals(); } } const gatewayCallOpts = (cmd: Command) => cmd .option( "--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)", ) .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (password auth)") .option("--timeout ", "Timeout in ms", "10000") .option("--expect-final", "Wait for final response (agent)", false); const callGatewayCli = async ( method: string, opts: GatewayRpcOpts, params?: unknown, ) => withProgress( { label: `Gateway ${method}`, indeterminate: true, enabled: true, }, async () => await callGateway({ url: opts.url, token: opts.token, password: opts.password, method, params, expectFinal: Boolean(opts.expectFinal), timeoutMs: Number(opts.timeout ?? 10_000), clientName: "cli", mode: "cli", }), ); export function registerGatewayCli(program: Command) { const gateway = program .command("gateway") .description("Run the WebSocket Gateway") .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", 'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).', ) .option( "--token ", "Shared token required in connect.params.auth.token (default: CLAWDBOT_GATEWAY_TOKEN env if set)", ) .option("--auth ", 'Gateway auth mode ("token"|"password")') .option("--password ", "Password for auth mode=password") .option( "--tailscale ", 'Tailscale exposure mode ("off"|"serve"|"funnel")', ) .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( "--force", "Kill any existing listener on the target port before starting", false, ) .option("--verbose", "Verbose logging to stdout/stderr", false) .option( "--ws-log