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