Files
openclaw/src/cli/gateway-cli/run.ts
Peter Steinberger 9497450511 refactor: eliminate dead-export baseline (#108376)
Burn the grandfathered unused-export baseline to zero and enforce a hard-zero Knip gate.
2026-07-15 17:05:07 +01:00

1106 lines
38 KiB
TypeScript

// Gateway run option resolution and local server startup command implementation.
import fs from "node:fs";
import { request } from "node:http";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type {
ConfigFileSnapshot,
GatewayAuthMode,
GatewayBindMode,
GatewayTailscaleMode,
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../../config/config.js";
import { ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV } from "../../config/future-version-guard.js";
import { isInvalidConfigError } from "../../config/io.invalid-config.js";
import {
CONFIG_PATH,
normalizeStateDirEnv,
resolveGatewayPort,
resolveStateDir,
} from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js";
import {
defaultGatewayBindMode,
isContainerEnvironment,
isLoopbackHost,
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 {
completeGatewayBootLifecycle,
GATEWAY_CRASH_LOOP_BREAKER_REASON,
GATEWAY_CRASH_LOOP_RECOVERED_REASON,
inspectGatewayCrashLoopBreaker,
recordGatewayBootStart,
type GatewayCrashLoopBreakerDecision,
type GatewayBootLifecycleCompletion,
} from "../../infra/gateway-boot-lifecycle.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.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 { printClawBanner, type ClawBannerResult } from "../claw-banner.js";
import { formatCliCommand } from "../command-format.js";
import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js";
import { withProgress } from "../progress.js";
import { parsePort } from "../shared/parse-port.js";
import {
enforceGatewayRunFutureConfigGuard,
isGatewayRunFutureConfigAllowed,
} from "./future-config-guard.js";
import { installQaParentWatchdog } from "./qa-parent-watchdog.js";
import { runGatewayLoop } from "./run-loop.js";
import type { GatewayRunOpts } from "./run-options.js";
import type { GatewayRunRuntimeHooks } from "./runtime-hooks.js";
const gatewayLog = createSubsystemLogger("gateway");
const SUPERVISED_GATEWAY_LOCK_RETRY_MS = 5000;
const SUPERVISED_GATEWAY_LOCK_RETRY_TIMEOUT_MS = 30_000;
const SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS = 1000;
const GATEWAY_SHELL_ENV_CONVERGENCE_MAX_READS = 4;
type Awaitable<T> = T | Promise<T>;
type GatewayRunLogger = Pick<ReturnType<typeof createSubsystemLogger>, "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;
} {
// Detect common token misplacements before startup falls back to unauthenticated mode.
if (!parsed || typeof parsed !== "object") {
return { hasGatewayToken: false, hasRemoteToken: false };
}
const gateway = (parsed as Record<string, unknown>).gateway;
if (!gateway || typeof gateway !== "object") {
return { hasGatewayToken: false, hasRemoteToken: false };
}
const hasGatewayToken = "token" in (gateway as Record<string, unknown>);
const remote = (gateway as Record<string, unknown>).remote;
const hasRemoteToken =
remote && typeof remote === "object" ? "token" in (remote as Record<string, unknown>) : 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`,
);
}
};
const startMeasure = <T>(name: string, run: () => Awaitable<T>) => {
const before = performance.now();
let completedAt = before;
let emitted = false;
const result = withDiagnosticPhase(name, run).finally(() => {
completedAt = performance.now();
});
// Attach both outcomes immediately so callers can finish terminal UI before
// consuming or rethrowing the measured result without an unhandled rejection.
const settled = result.then(
() => {},
() => {},
);
return {
result,
settled,
emit() {
if (emitted) {
return;
}
emitted = true;
emit(name, completedAt - before, completedAt - started);
last = completedAt;
},
};
};
return {
mark(name: string) {
const now = performance.now();
emit(name, now - last, now - started);
last = now;
},
startMeasure,
async measure<T>(name: string, run: () => Awaitable<T>): Promise<T> {
const measurement = startMeasure(name, run);
try {
return await measurement.result;
} finally {
await measurement.settled;
measurement.emit();
}
},
};
}
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<string | undefined> {
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<T extends string>(
raw: string | undefined,
allowed: readonly T[],
): T | null {
if (!raw) {
return null;
}
return (allowed as readonly string[]).includes(raw) ? (raw as T) : null;
}
function formatModeErrorList(modes: readonly string[]): string {
const quoted = modes.map((mode) => `"${mode}"`);
if (quoted.length === 0) {
return "";
}
if (quoted.length === 1) {
return expectDefined(quoted[0], "quoted entry at 0");
}
if (quoted.length === 2) {
return `${quoted[0]} or ${quoted[1]}`;
}
return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`;
}
function shouldBlockGatewayBindWithoutExplicitAuth(params: {
bindHost: string;
hasSharedSecret: boolean;
resolvedAuthMode: GatewayAuthMode;
}): boolean {
return (
!isLoopbackHost(params.bindHost) &&
!params.hasSharedSecret &&
params.resolvedAuthMode !== "trusted-proxy"
);
}
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: {
lowerPrecedenceEnv: Readonly<Record<string, string>>;
opts: GatewayRunOpts;
startupTrace: ReturnType<typeof createGatewayCliStartupTrace>;
}): Promise<{
cfg: OpenClawConfig;
snapshot: ConfigFileSnapshot | null;
startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
}> {
const { readConfigFileSnapshotWithPluginMetadata } = await import("../../config/config.js");
let blockedRecoveryConfig: OpenClawConfig | null = null;
const snapshotRead: ReadConfigFileSnapshotWithPluginMetadataResult | null =
await params.startupTrace.measure("cli.config-snapshot", () =>
readConfigFileSnapshotWithPluginMetadata({
isolateEnv: true,
...(Object.keys(params.lowerPrecedenceEnv).length > 0
? { lowerPrecedenceEnv: params.lowerPrecedenceEnv }
: {}),
recoverSuspicious: true,
allowSuspiciousRecovery: (config, current) => {
const blockedConfig = [current, config].find(
(candidate) =>
!isGatewayRunFutureConfigAllowed({ opts: params.opts, config: candidate }),
);
if (!blockedConfig) {
return true;
}
blockedRecoveryConfig = blockedConfig;
return false;
},
}).catch(() => null),
);
if (blockedRecoveryConfig) {
enforceGatewayRunFutureConfigGuard({
opts: params.opts,
runtime: defaultRuntime,
config: blockedRecoveryConfig,
});
}
const snapshot: ConfigFileSnapshot | null = snapshotRead?.snapshot ?? null;
const cfg = snapshot?.config ?? {};
return {
cfg,
snapshot,
...(snapshotRead ? { startupConfigSnapshotRead: snapshotRead } : {}),
};
}
type GatewayRunShellEnvFallbackPlan =
| { enabled: false }
| {
enabled: true;
expectedKeys: string[];
timeoutMs: number;
};
async function resolveGatewayRunShellEnvFallbackPlan(
cfg: OpenClawConfig,
): Promise<GatewayRunShellEnvFallbackPlan> {
const { createConfigRuntimeEnv } = await import("../../config/env-vars.js");
const {
resolveShellEnvFallbackTimeoutMs,
shouldDeferShellEnvFallback,
shouldEnableShellEnvFallback,
} = await import("../../infra/shell-env.js");
const planEnv = createConfigRuntimeEnv(cfg, process.env);
const enabled =
(shouldEnableShellEnvFallback(planEnv) || cfg.env?.shellEnv?.enabled === true) &&
!shouldDeferShellEnvFallback(planEnv);
if (!enabled) {
return { enabled: false };
}
const { resolveShellEnvExpectedKeys } = await import("../../config/shell-env-expected-keys.js");
return {
enabled: true,
expectedKeys: resolveShellEnvExpectedKeys(planEnv),
timeoutMs: cfg.env?.shellEnv?.timeoutMs ?? resolveShellEnvFallbackTimeoutMs(planEnv),
};
}
async function loadGatewayRunShellEnvFallback(
plan: Extract<GatewayRunShellEnvFallbackPlan, { enabled: true }>,
): Promise<Record<string, string>> {
const { loadShellEnvFallback } = await import("../../infra/shell-env.js");
const valuesBeforeLoad = new Map(plan.expectedKeys.map((key) => [key, process.env[key]]));
loadShellEnvFallback({
enabled: true,
env: process.env,
expectedKeys: plan.expectedKeys,
logger: gatewayLog,
timeoutMs: plan.timeoutMs,
});
return Object.fromEntries(
plan.expectedKeys.flatMap((key) => {
const value = process.env[key];
return value !== undefined && value !== valuesBeforeLoad.get(key) ? [[key, value]] : [];
}),
);
}
async function clearGatewayRunShellEnvFallback(
values: Readonly<Record<string, string>>,
): Promise<void> {
const keys = Object.keys(values);
if (keys.length === 0) {
return;
}
for (const [key, value] of Object.entries(values)) {
if (process.env[key] === value) {
delete process.env[key];
}
}
const { clearShellEnvAppliedKeys } = await import("../../infra/shell-env.js");
clearShellEnvAppliedKeys(keys);
}
function gatewayRunShellEnvFallbackPlanSignature(plan: GatewayRunShellEnvFallbackPlan): string {
return JSON.stringify(plan);
}
async function readGatewayStartupConfigWithShellEnv(params: {
opts: GatewayRunOpts;
startupTrace: ReturnType<typeof createGatewayCliStartupTrace>;
}): Promise<
Awaited<ReturnType<typeof readGatewayStartupConfig>> & {
lowerPrecedenceEnv: Readonly<Record<string, string>>;
}
> {
let lowerPrecedenceEnv: Record<string, string> = {};
let loadedPlanSignature: string | undefined;
try {
for (let readCount = 0; readCount < GATEWAY_SHELL_ENV_CONVERGENCE_MAX_READS; readCount += 1) {
const startupConfig = await readGatewayStartupConfig({
lowerPrecedenceEnv,
opts: params.opts,
startupTrace: params.startupTrace,
});
const plan = await resolveGatewayRunShellEnvFallbackPlan(
startupConfig.snapshot?.valid === true ? startupConfig.cfg : {},
);
const planSignature = gatewayRunShellEnvFallbackPlanSignature(plan);
if (!plan.enabled) {
if (Object.keys(lowerPrecedenceEnv).length === 0) {
return { ...startupConfig, lowerPrecedenceEnv };
}
await clearGatewayRunShellEnvFallback(lowerPrecedenceEnv);
lowerPrecedenceEnv = {};
loadedPlanSignature = undefined;
continue;
}
if (loadedPlanSignature === planSignature) {
return { ...startupConfig, lowerPrecedenceEnv };
}
await clearGatewayRunShellEnvFallback(lowerPrecedenceEnv);
lowerPrecedenceEnv = await loadGatewayRunShellEnvFallback(plan);
loadedPlanSignature = planSignature;
}
} catch (err) {
await clearGatewayRunShellEnvFallback(lowerPrecedenceEnv);
throw err;
}
await clearGatewayRunShellEnvFallback(lowerPrecedenceEnv);
throw new Error(
"Gateway shell environment fallback settings changed repeatedly during startup. Retry startup.",
);
}
function isGatewayLockError(err: unknown): err is GatewayLockError {
return (
err instanceof GatewayLockError ||
(Boolean(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 resolveGatewayStartupFailureExitCode(err: unknown): number {
return isInvalidConfigError(err) ? EXIT_CONFIG_ERROR : 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<boolean> {
const timeoutMs = params.timeoutMs ?? SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS;
return await new Promise<boolean>((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<void>;
supervisor: RespawnSupervisor | null;
port: number;
healthHost: string;
log: GatewayRunLogger;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
probeHealth?: (params: { host: string; port: number }) => Promise<boolean>;
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,
reason = "gateway.startup_failed",
): Promise<void> {
const { writeDiagnosticStabilityBundleForFailureSync } =
await import("../../logging/diagnostic-stability-bundle.js");
const result = writeDiagnosticStabilityBundleForFailureSync(reason, err);
if ("message" in result) {
gatewayLog.warn(result.message);
}
}
export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunRuntimeHooks = {}) {
// Reparenting can hide the running service from the ancestor walk.
// Preserve its inherited PID before config env rebuilding overwrites it.
const inheritedGatewayServicePid = parseStrictPositiveInteger(
process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV],
);
normalizeStateDirEnv(process.env);
const { clearGatewayRunConfigEnvironment } = await import("./pre-bootstrap.js");
clearGatewayRunConfigEnvironment();
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.). Start it before the foreground TTY
// banner so the animation never extends readiness. If loading wins, the
// banner settles cleanly; otherwise its existing spinner owns the wait.
const serverImportMeasurement = startupTrace.startMeasure(
"cli.server-import",
() => import("../../gateway/server.js"),
);
const rawServerImport = serverImportMeasurement.result;
const bannerDone: Promise<ClawBannerResult> = process.stdout.isTTY
? printClawBanner(defaultRuntime, { settleWhen: rawServerImport })
: Promise.resolve("static");
const loadServerModule = async () => {
try {
const bannerResult = await bannerDone;
return bannerResult === "settled"
? await rawServerImport
: await withProgress(
{ label: "Loading gateway modules…", indeterminate: true },
async () => rawServerImport,
);
} finally {
// Trace output follows banner or spinner cleanup on both success and error.
await serverImportMeasurement.settled;
serverImportMeasurement.emit();
}
};
const { startGatewayServer } = await loadServerModule();
setConsoleTimestampPrefix(true);
if (devMode) {
if (opts.reset) {
// Recheck immediately before full reset; gateway module loading above can take seconds.
const { recheckGatewayRunReset } = await import("./pre-bootstrap.js");
if (!(await recheckGatewayRunReset({ opts, runtime: defaultRuntime }))) {
return;
}
}
const { ensureDevGatewayConfig } = await import("./dev.js");
await startupTrace.measure("cli.dev-config", () =>
ensureDevGatewayConfig({ reset: Boolean(opts.reset) }),
);
if (opts.reset) {
const { reloadTrustedGatewayRunEnvironment } = await import("./pre-bootstrap.js");
if (!(await reloadTrustedGatewayRunEnvironment({ runtime: defaultRuntime }))) {
return;
}
}
}
gatewayLog.info("loading configuration…");
const { cfg, lowerPrecedenceEnv, snapshot, startupConfigSnapshotRead } =
await readGatewayStartupConfigWithShellEnv({
opts,
startupTrace,
});
if (
!enforceGatewayRunFutureConfigGuard({
opts,
runtime: defaultRuntime,
snapshot,
})
) {
return;
}
if (snapshot) {
const { applyFinalGatewayRunConfigEnv } = await import("./pre-bootstrap.js");
if (
!(await applyFinalGatewayRunConfigEnv({
lowerPrecedenceEnv,
runtime: defaultRuntime,
snapshot,
}))
) {
return;
}
const finalConfigEnteredServiceMode = Boolean(process.env.OPENCLAW_SERVICE_MARKER?.trim());
const clearRejectedFinalConfigEnv = () => {
clearGatewayRunConfigEnvironment();
if (finalConfigEnteredServiceMode) {
delete process.env[ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV];
}
};
let finalConfigAllowed: boolean;
try {
finalConfigAllowed = enforceGatewayRunFutureConfigGuard({
opts,
runtime: defaultRuntime,
snapshot,
});
} catch (err) {
clearRejectedFinalConfigEnv();
throw err;
}
if (!finalConfigAllowed) {
clearRejectedFinalConfigEnv();
return;
}
}
if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) {
process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV] = String(process.pid);
}
await hooks.refreshManagedProxy?.(cfg.proxy);
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(EXIT_CONFIG_ERROR);
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<string>(["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, {
protectedPid: inheritedGatewayServicePid,
});
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) {
// Nothing was freed; keep the no-op out of normal startup output.
gatewayLog.debug(`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 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.",
);
}
const healthHost = await resolveGatewayBindHost(bind, cfg.gateway?.customBindHost);
if (
shouldBlockGatewayBindWithoutExplicitAuth({
bindHost: healthHost,
hasSharedSecret,
resolvedAuthMode,
})
) {
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");
let startupConfigSnapshotReadForNextStart = startupConfigSnapshotRead;
const envSidecarStartupMode =
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS)
? "defer"
: "start";
let crashLoopDecision: GatewayCrashLoopBreakerDecision | undefined;
let channelAutostartSuppression: { reason: "crash-loop-breaker"; message: string } | undefined;
let activeBootId: string | undefined;
const beginBoot = async (startedAtMs: number) => {
// run-loop calls beginBoot before every startGatewayServer invocation, so
// in-process restarts re-evaluate breaker state instead of reusing stale mode.
crashLoopDecision = inspectGatewayCrashLoopBreaker(process.env, startedAtMs);
const bootStartReason = crashLoopDecision.tripped
? crashLoopDecision.shouldWriteStabilityBundle
? GATEWAY_CRASH_LOOP_BREAKER_REASON
: undefined
: crashLoopDecision.recovered
? GATEWAY_CRASH_LOOP_RECOVERED_REASON
: undefined;
activeBootId = recordGatewayBootStart(process.env, startedAtMs, bootStartReason);
channelAutostartSuppression = undefined;
if (crashLoopDecision.recovered) {
gatewayLog.info("gateway restart-loop breaker recovered; channel auto-start restored");
}
if (!crashLoopDecision.tripped) {
return;
}
const message =
`gateway restart-loop breaker tripped: ${crashLoopDecision.uncleanBoots} unclean boot(s) within ${crashLoopDecision.windowMs}ms; ` +
"suppressing channel/provider account auto-start. Inspect the stability bundle and fix the startup crash before restarting the service.";
channelAutostartSuppression = { reason: "crash-loop-breaker", message };
gatewayLog.error(message);
if (crashLoopDecision.shouldWriteStabilityBundle) {
await maybeWriteGatewayStartupFailureBundle(
new Error(message),
GATEWAY_CRASH_LOOP_BREAKER_REASON,
);
}
};
const completeBoot = (completion: GatewayBootLifecycleCompletion) => {
completeGatewayBootLifecycle(activeBootId, completion, process.env);
activeBootId = undefined;
};
const startLoop = async () =>
await runGatewayLoop({
runtime: defaultRuntime,
lockPort: port,
healthHost,
beginBoot,
completeBoot,
start: async ({ startupStartedAt, requestHotReloadRecovery } = {}) => {
const startupConfigSnapshotReadForThisStart = startupConfigSnapshotReadForNextStart;
startupConfigSnapshotReadForNextStart = undefined;
return await startGatewayServer(port, {
bind,
auth: authOverride,
tailscale: tailscaleOverride,
startupStartedAt,
...(requestHotReloadRecovery ? { hotReloadRecovery: requestHotReloadRecovery } : {}),
...(startupConfigSnapshotReadForThisStart
? { startupConfigSnapshotRead: startupConfigSnapshotReadForThisStart }
: {}),
...(envSidecarStartupMode !== "start" ? { sidecarStartup: envSidecarStartupMode } : {}),
...(channelAutostartSuppression ? { channelAutostartSuppression } : {}),
});
},
});
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(resolveGatewayStartupFailureExitCode(err));
}
}
const testing = {
normalizeGatewayHealthProbeHost,
resolveGatewayLockErrorExitCode,
resolveGatewayStartupFailureExitCode,
runGatewayLoopWithSupervisedLockRecovery,
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.gatewayRunTestApi")] = testing;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */