Download src/cli/gateway-cli/run.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 46.2 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/gateway-cli/run.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/cli/gateway-cli/run.ts
-
curl -L -o run.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/gateway-cli/run.ts
46.2 kB
| // Gateway run option resolution and local server startup command implementation. | |
| import fs from "node:fs"; | |
| import { expectDefined } from "@openclaw/normalization-core"; | |
| import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; | |
| import { | |
| normalizeOptionalLowercaseString, | |
| normalizeOptionalString, | |
| } from "@openclaw/normalization-core/string-coerce"; | |
| import { triageAfterFailure } from "../../commands/triage-failure.js"; | |
| 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 { | |
| isDoctorRecoverableInvalidConfigError, | |
| isInvalidConfigError, | |
| } from "../../config/io.invalid-config.js"; | |
| import { CONFIG_PATH, normalizeStateDirEnv, resolveGatewayPort } 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 { | |
| createConfiguredGatewayLocalProbe, | |
| normalizeGatewayHttpProbeHost, | |
| requestGatewayLocalHttpProbe, | |
| } from "../../gateway/local-http-probe.js"; | |
| import { | |
| defaultGatewayBindMode, | |
| isContainerEnvironment, | |
| isLoopbackHost, | |
| resolveGatewayBindHost, | |
| } from "../../gateway/net.js"; | |
| import { isGatewayEffectiveConfigConflictError } from "../../gateway/server-runtime-config.js"; | |
| import { GatewayStartupCleanupError } from "../../gateway/server-shutdown.js"; | |
| import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; | |
| import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; | |
| import { setVerbose } from "../../globals.js"; | |
| import { isAbortError } from "../../infra/abort-signal.js"; | |
| import { isTruthyEnvValue } from "../../infra/env.js"; | |
| import { collectNestedErrorCandidates } from "../../infra/error-graph-internal.js"; | |
| import { formatErrorMessage } from "../../infra/errors.js"; | |
| import { | |
| completeGatewayBootLifecycle, | |
| GATEWAY_CRASH_LOOP_BREAKER_REASON, | |
| formatGatewayCrashLoopManualChannelStartHint, | |
| GATEWAY_CRASH_LOOP_RECOVERED_REASON, | |
| inspectGatewayCrashLoopBreaker, | |
| recordGatewayBootStart, | |
| recordGatewayCrashLoopRecovery, | |
| type GatewayCrashLoopBreakerDecision, | |
| type GatewayBootLifecycleCompletion, | |
| } from "../../infra/gateway-boot-lifecycle.js"; | |
| import { | |
| GATEWAY_LIFECYCLE_LOCK_TIMEOUT_MS, | |
| GatewayLockError, | |
| isGatewayLifecycleContentionError, | |
| } from "../../infra/gateway-lock.js"; | |
| import { | |
| findVerifiedGatewayListenerPidsOnPortSync, | |
| formatGatewayPidList, | |
| } from "../../infra/gateway-processes.js"; | |
| import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; | |
| import { isTailscaleRouteOwnershipConflictError } from "../../infra/tailscale-route-ownership-error.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 type { InvalidConfigRecoveryDeps } from "../invalid-config-recovery.js"; | |
| import { withProgress } from "../progress.js"; | |
| import { parsePort } from "../shared/parse-port.js"; | |
| import { | |
| isTerminalInteractive, | |
| NON_INTERACTIVE_GATEWAY_RUN_FORCE_MESSAGE, | |
| } from "../terminal-interactivity.js"; | |
| import { enforceGatewayRunFutureConfigGuard } from "./future-config-guard.js"; | |
| import { getGatewayStartGuardErrors } from "./pre-bootstrap.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"; | |
| import { resolveGatewayStartupMaintenanceReason } from "./startup-maintenance.js"; | |
| const gatewayLog = createSubsystemLogger("gateway"); | |
| const SUPERVISED_GATEWAY_LOCK_RETRY_MS = 5000; | |
| 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" | |
| ); | |
| } | |
| async function readGatewayStartupConfig(params: { | |
| lowerPrecedenceEnv: Readonly<Record<string, string>>; | |
| startupTrace: ReturnType<typeof createGatewayCliStartupTrace>; | |
| }): 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({ | |
| isolateEnv: true, | |
| observe: false, | |
| ...(Object.keys(params.lowerPrecedenceEnv).length > 0 | |
| ? { lowerPrecedenceEnv: params.lowerPrecedenceEnv } | |
| : {}), | |
| }).catch(() => null), | |
| ); | |
| 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, cfg), | |
| 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: { | |
| 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, | |
| 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 isGatewayRetryableLockError(err: unknown): boolean { | |
| if (!isGatewayLockError(err) || typeof err.message !== "string") { | |
| return false; | |
| } | |
| return ( | |
| isGatewayLifecycleContentionError(err) || | |
| err.message.includes("gateway already running") || | |
| err.message.includes("another gateway instance is already listening") | |
| ); | |
| } | |
| class SupervisedGatewayLockError extends GatewayLockError { | |
| constructor( | |
| message: string, | |
| cause: unknown, | |
| readonly exitCode: 1 | typeof EXIT_CONFIG_ERROR, | |
| ) { | |
| super(message, cause); | |
| } | |
| } | |
| function resolveGatewayLockErrorExitCode(err: unknown): number { | |
| return err instanceof SupervisedGatewayLockError ? err.exitCode : 1; | |
| } | |
| function resolveGatewayStartupFailureExitCode(err: unknown): number { | |
| return isInvalidConfigError(err) || | |
| isTailscaleRouteOwnershipConflictError(err) || | |
| isGatewayEffectiveConfigConflictError(err) || | |
| resolveGatewayStartupMaintenanceReason(err) | |
| ? EXIT_CONFIG_ERROR | |
| : 1; | |
| } | |
| const normalizeGatewayHealthProbeHost = normalizeGatewayHttpProbeHost; | |
| function isGatewayHealthzResponse(statusCode: number | undefined, body: string): boolean { | |
| if (statusCode !== 200) { | |
| return false; | |
| } | |
| try { | |
| const payload = JSON.parse(body) as { ok?: unknown; status?: unknown }; | |
| return payload.ok === true && payload.status === "live"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function probeGatewayHealthz(params: { | |
| host: string; | |
| port: number; | |
| timeoutMs?: number; | |
| tlsFingerprint?: string; | |
| }): Promise<boolean> { | |
| const timeoutMs = params.timeoutMs ?? SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS; | |
| const result = await requestGatewayLocalHttpProbe({ | |
| ...params, | |
| pathname: "/healthz", | |
| timeoutMs, | |
| }); | |
| return isGatewayHealthzResponse(result?.statusCode, result?.body ?? ""); | |
| } | |
| function createConfiguredGatewayHealthProbe(cfg: OpenClawConfig) { | |
| const probe = createConfiguredGatewayLocalProbe(cfg); | |
| return async (params: { host: string; port: number }): Promise<boolean> => { | |
| const result = await probe.requestHttp({ | |
| ...params, | |
| pathname: "/healthz", | |
| timeoutMs: SUPERVISED_GATEWAY_HEALTH_PROBE_TIMEOUT_MS, | |
| }); | |
| return isGatewayHealthzResponse(result?.statusCode, result?.body ?? ""); | |
| }; | |
| } | |
| async function runGatewayLoopWithSupervisedLockRecovery(params: { | |
| startLoop: (lifecycleDeadlineMs?: number) => 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 ?? performance.now.bind(performance); | |
| 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 ?? GATEWAY_LIFECYCLE_LOCK_TIMEOUT_MS; | |
| const startedAt = now(); | |
| for (;;) { | |
| try { | |
| // Acquisition and supervised recovery spend the same monotonic budget. | |
| await params.startLoop(startedAt + timeoutMs); | |
| return; | |
| } catch (err) { | |
| if (!isGatewayRetryableLockError(err)) { | |
| throw err; | |
| } | |
| const lifecycleContention = isGatewayLifecycleContentionError(err); | |
| if ( | |
| !lifecycleContention && | |
| (await probeHealth({ host: params.healthHost, port: params.port })) | |
| ) { | |
| if (supervisor === "systemd") { | |
| throw new SupervisedGatewayLockError( | |
| "gateway already running under systemd; existing gateway is healthy, exiting with code 78 to prevent a systemd Restart=always loop", | |
| err, | |
| EXIT_CONFIG_ERROR, | |
| ); | |
| } | |
| params.log.info( | |
| `gateway already running under ${supervisor}; existing gateway is healthy, leaving it in control`, | |
| ); | |
| return; | |
| } | |
| const elapsedMs = now() - startedAt; | |
| if (elapsedMs >= timeoutMs) { | |
| if (lifecycleContention) { | |
| throw err; | |
| } | |
| throw new SupervisedGatewayLockError( | |
| `gateway already running under ${supervisor}; existing gateway did not become healthy after ${timeoutMs}ms`, | |
| err, | |
| 1, | |
| ); | |
| } | |
| const waitMs = Math.min(retryMs, Math.max(0, timeoutMs - elapsedMs)); | |
| params.log.warn( | |
| `${lifecycleContention ? "gateway-lifecycle ownership held by another OpenClaw process" : "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); | |
| } | |
| } | |
| async function runGatewayCommandOnce(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; | |
| // Gateways inherit the launching shell, so suppress ambient channel credentials unless the | |
| // operator explicitly allows them for this run. | |
| const ambientEnvTriggers = opts.ambientChannels || opts.devAmbientChannels ? "allow" : "suppress"; | |
| 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({ | |
| 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); | |
| if (process.platform === "darwin") { | |
| const { warnAboutGatewayRestartStorm } = await import("../../daemon/restart-storm.js"); | |
| await warnAboutGatewayRestartStorm(process.env, (message) => gatewayLog.warn(message)); | |
| } | |
| } | |
| 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}`, | |
| ); | |
| // A repeated stale-kill on a managed host can be the symptom of two | |
| // supervisors (a user-scope + a system-scope systemd unit) evicting each | |
| // other in a restart loop (issue #79375). Surface the dueling condition | |
| // with concrete remediation instead of letting it look like routine | |
| // cleanup. Gated on an actual eviction so clean starts pay no cost. | |
| if (process.platform === "linux") { | |
| const { findSystemdGatewayInstallation, formatDuelingScopesWarning } = | |
| await import("../../daemon/systemd.js"); | |
| const installation = await findSystemdGatewayInstallation(process.env).catch(() => null); | |
| const warning = installation ? formatDuelingScopesWarning(installation, port) : null; | |
| if (warning) { | |
| gatewayLog.warn(`service-mode: ${warning}`); | |
| } | |
| } | |
| } | |
| } | |
| if (opts.force) { | |
| const interactive = isTerminalInteractive(); | |
| const describeNonInteractiveGatewayOwner = () => { | |
| const gatewayPids = findVerifiedGatewayListenerPidsOnPortSync(port); | |
| if (gatewayPids.length === 0) { | |
| return undefined; | |
| } | |
| return `${NON_INTERACTIVE_GATEWAY_RUN_FORCE_MESSAGE} Existing gateway listener pid${gatewayPids.length === 1 ? "" : "s"}: ${formatGatewayPidList(gatewayPids)}.`; | |
| }; | |
| if (!interactive) { | |
| const refusal = describeNonInteractiveGatewayOwner(); | |
| if (refusal) { | |
| defaultRuntime.error(refusal); | |
| defaultRuntime.exit(1); | |
| return; | |
| } | |
| } | |
| try { | |
| const { forceFreePortAndWait, waitForPortBindable } = await import("../ports.js"); | |
| const { killed, waitedMs, escalatedToSigkill } = await forceFreePortAndWait(port, { | |
| timeoutMs: 2000, | |
| intervalMs: 100, | |
| sigtermTimeoutMs: 700, | |
| ...(interactive | |
| ? {} | |
| : { | |
| beforeSignal: () => { | |
| const refusal = describeNonInteractiveGatewayOwner(); | |
| if (refusal) { | |
| throw new Error(refusal); | |
| } | |
| }, | |
| }), | |
| }); | |
| 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 effectiveCfg = snapshot?.valid ? snapshot.config : cfg; | |
| const mode = effectiveCfg.gateway?.mode; | |
| const guardErrors = getGatewayStartGuardErrors({ | |
| allowUnconfigured: opts.allowUnconfigured, | |
| configExists, | |
| 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 ? { mode: tailscaleMode } : 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 tryRecoverChannelAutostartSuppression: (() => boolean) | undefined; | |
| let activeBootId: string | undefined; | |
| let bootRecorded = false; | |
| let triageAttempted = false; | |
| const triageStartupFailure = async (error: unknown, signal?: AbortSignal) => { | |
| if ( | |
| triageAttempted || | |
| !bootRecorded || | |
| signal?.aborted || | |
| isAbortError(error) || | |
| isGatewayLockError(error) || | |
| isInvalidConfigError(error) || | |
| isTailscaleRouteOwnershipConflictError(error) || | |
| isGatewayEffectiveConfigConflictError(error) || | |
| collectNestedErrorCandidates(error).some( | |
| (candidate) => candidate instanceof GatewayStartupCleanupError, | |
| ) || | |
| resolveGatewayStartupMaintenanceReason(error) | |
| ) { | |
| return; | |
| } | |
| // Unconfirmed startup cleanup retains its generation; never overlap it with a fixer. | |
| // Supervised retries reuse the persisted breaker transition to avoid agent storms. | |
| if ( | |
| (supervisor || process.env.OPENCLAW_SERVICE_MARKER) && | |
| !crashLoopDecision?.shouldWriteStabilityBundle | |
| ) { | |
| return; | |
| } | |
| triageAttempted = true; | |
| await triageAfterFailure( | |
| defaultRuntime, | |
| { | |
| kind: "gateway-startup", | |
| phase: "startup", | |
| error: formatErrorMessage(error), | |
| gateway: "verify-running", | |
| }, | |
| signal, | |
| ); | |
| }; | |
| 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; | |
| // Shared-state schema failures make this write fail open, so no lifecycle | |
| // row exists for Doctor to reconcile after it repairs the schema. | |
| activeBootId = recordGatewayBootStart(process.env, startedAtMs, bootStartReason); | |
| bootRecorded = activeBootId !== undefined; | |
| channelAutostartSuppression = undefined; | |
| tryRecoverChannelAutostartSuppression = 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. ${formatGatewayCrashLoopManualChannelStartHint()}`; | |
| channelAutostartSuppression = { reason: "crash-loop-breaker", message }; | |
| const suppressedBootId = activeBootId; | |
| tryRecoverChannelAutostartSuppression = () => { | |
| if (!suppressedBootId || activeBootId !== suppressedBootId) { | |
| return false; | |
| } | |
| const decision = inspectGatewayCrashLoopBreaker(process.env); | |
| // The current safe-mode boot remains an open row until the full window has | |
| // drained. Requiring zero prevents a near-expiry history from restoring | |
| // channels before this process itself has proven stable for the whole window. | |
| if (!decision.recovered || decision.uncleanBoots !== 0) { | |
| return false; | |
| } | |
| const recoveredBootId = recordGatewayCrashLoopRecovery(suppressedBootId, process.env); | |
| if (!recoveredBootId || activeBootId !== suppressedBootId) { | |
| return false; | |
| } | |
| activeBootId = recoveredBootId; | |
| gatewayLog.info("gateway restart-loop breaker recovered; channel auto-start restored"); | |
| return true; | |
| }; | |
| 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 (lifecycleLockDeadlineMs?: number) => | |
| await runGatewayLoop({ | |
| runtime: defaultRuntime, | |
| ownsProcessLifecycle: true, | |
| lockPort: port, | |
| lifecycleLockDeadlineMs, | |
| healthHost, | |
| beginBoot, | |
| completeBoot, | |
| onRestartStartupFailure: triageStartupFailure, | |
| start: async ({ | |
| processStartedAt, | |
| startupStartedAt, | |
| requestHotReloadRecovery, | |
| hostLifecycle, | |
| startupOperation, | |
| } = {}) => { | |
| const startupConfigSnapshotReadForThisStart = startupConfigSnapshotReadForNextStart; | |
| startupConfigSnapshotReadForNextStart = undefined; | |
| return await startGatewayServer(port, { | |
| bind, | |
| ...(opts.updateCanary ? { updateCanary: true } : {}), | |
| ...(activeBootId ? { bootId: activeBootId } : {}), | |
| auth: authOverride, | |
| tailscale: tailscaleOverride, | |
| ...(processStartedAt !== undefined ? { processStartedAt } : {}), | |
| startupStartedAt, | |
| hostLifecycle, | |
| startupOperation, | |
| ...(requestHotReloadRecovery ? { hotReloadRecovery: requestHotReloadRecovery } : {}), | |
| ...(startupConfigSnapshotReadForThisStart | |
| ? { startupConfigSnapshotRead: startupConfigSnapshotReadForThisStart } | |
| : {}), | |
| ...(envSidecarStartupMode !== "start" ? { sidecarStartup: envSidecarStartupMode } : {}), | |
| ...(channelAutostartSuppression ? { channelAutostartSuppression } : {}), | |
| ...(channelAutostartSuppression ? { tryRecoverChannelAutostartSuppression } : {}), | |
| ambientEnvTriggers, | |
| }); | |
| }, | |
| }); | |
| const { detectRespawnSupervisor } = await import("../../infra/supervisor-markers.js"); | |
| const supervisor = detectRespawnSupervisor(process.env); | |
| try { | |
| await runGatewayLoopWithSupervisedLockRecovery({ | |
| startLoop, | |
| supervisor, | |
| port, | |
| healthHost, | |
| log: gatewayLog, | |
| probeHealth: createConfiguredGatewayHealthProbe(cfg), | |
| }); | |
| } 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 Promise.all([ | |
| import("../../infra/ports-format.js"), | |
| import("../../infra/ports-inspect.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)); | |
| return; | |
| } | |
| if (isInvalidConfigError(err)) { | |
| throw err; | |
| } | |
| await maybeWriteGatewayStartupFailureBundle(err); | |
| if (resolveGatewayStartupMaintenanceReason(err)) { | |
| throw err; | |
| } | |
| defaultRuntime.error( | |
| `Gateway failed to start: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} for diagnostics.`, | |
| ); | |
| await triageStartupFailure(err); | |
| defaultRuntime.exit(resolveGatewayStartupFailureExitCode(err)); | |
| } | |
| } | |
| /** Run foreground Gateway startup with one consent-gated invalid-config repair attempt. */ | |
| export async function runGatewayCommand( | |
| opts: GatewayRunOpts, | |
| hooks: GatewayRunRuntimeHooks = {}, | |
| recoveryDeps?: InvalidConfigRecoveryDeps, | |
| ) { | |
| if (opts.taskSupervisor) { | |
| const { runWindowsGatewayTaskSupervisor } = await import("./task-supervisor.js"); | |
| await runWindowsGatewayTaskSupervisor(); | |
| return; | |
| } | |
| try { | |
| await runGatewayCommandOnce(opts, hooks); | |
| } catch (error) { | |
| if (!isInvalidConfigError(error)) { | |
| throw error; | |
| } | |
| defaultRuntime.error(`Gateway failed to start: ${formatErrorMessage(error)}`); | |
| if (opts.allowUnconfigured || !isDoctorRecoverableInvalidConfigError(error)) { | |
| defaultRuntime.exit(EXIT_CONFIG_ERROR); | |
| return; | |
| } | |
| const { offerInvalidConfigRecovery } = await import("../invalid-config-recovery.js"); | |
| const recovery = await offerInvalidConfigRecovery({ | |
| runtime: defaultRuntime, | |
| deps: recoveryDeps, | |
| retry: async () => await runGatewayCommandOnce(opts, hooks), | |
| }); | |
| if (recovery.status === "recovered") { | |
| return; | |
| } | |
| defaultRuntime.exit(EXIT_CONFIG_ERROR); | |
| } | |
| } | |
| const testing = { | |
| createConfiguredGatewayHealthProbe, | |
| isGatewayHealthzResponse, | |
| normalizeGatewayHealthProbeHost, | |
| probeGatewayHealthz, | |
| 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. */ | |