2026-03-12 09:17:16 +01:00

577 lines
19 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { readSecretFromFile } from "../../acp/secret-file.js";
import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js";
import {
loadConfig,
readConfigFileSnapshot,
resolveConfigPath,
resolveStateDir,
resolveGatewayPort,
} from "../../config/config.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import { startGatewayServer } from "../../gateway/server.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setVerbose } from "../../globals.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js";
import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import { inheritOptionFromParent } from "../command-options.js";
import { forceFreePortAndWait, waitForPortBindable } from "../ports.js";
import { isValidProfileName } from "../profile-utils.js";
import { ensureDevGatewayConfig } from "./dev.js";
import { runGatewayLoop } from "./run-loop.js";
import {
describeUnknownError,
extractGatewayMiskeys,
maybeExplainGatewayServiceStop,
parsePort,
toOptionString,
} from "./shared.js";
type GatewayRunOpts = {
port?: unknown;
bind?: unknown;
token?: unknown;
auth?: unknown;
password?: unknown;
passwordFile?: unknown;
tailscale?: unknown;
tailscaleResetOnExit?: boolean;
allowUnconfigured?: boolean;
force?: boolean;
verbose?: boolean;
claudeCliLogs?: boolean;
wsLog?: unknown;
compact?: boolean;
rawStream?: boolean;
rawStreamPath?: unknown;
dev?: boolean;
reset?: boolean;
};
const gatewayLog = createSubsystemLogger("gateway");
const GATEWAY_RUN_VALUE_KEYS = [
"port",
"bind",
"token",
"auth",
"password",
"passwordFile",
"tailscale",
"wsLog",
"rawStreamPath",
] as const;
const GATEWAY_RUN_BOOLEAN_KEYS = [
"tailscaleResetOnExit",
"allowUnconfigured",
"dev",
"reset",
"force",
"verbose",
"claudeCliLogs",
"compact",
"rawStream",
] as const;
const GATEWAY_AUTH_MODES: readonly GatewayAuthMode[] = [
"none",
"token",
"password",
"trusted-proxy",
];
const GATEWAY_TAILSCALE_MODES: readonly GatewayTailscaleMode[] = ["off", "serve", "funnel"];
function warnInlinePasswordFlag() {
defaultRuntime.error(
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
);
}
function resolveGatewayPasswordOption(opts: GatewayRunOpts): 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) {
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<T extends string>(modes: readonly T[]): string {
return modes.map((mode) => `"${mode}"`).join("|");
}
function formatModeErrorList<T extends string>(modes: readonly T[]): string {
const quoted = modes.map((mode) => `"${mode}"`);
if (quoted.length === 0) {
return "";
}
if (quoted.length === 1) {
return quoted[0];
}
if (quoted.length === 2) {
return `${quoted[0]} or ${quoted[1]}`;
}
return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`;
}
function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts {
const resolved: GatewayRunOpts = { ...opts };
for (const key of GATEWAY_RUN_VALUE_KEYS) {
const inherited = inheritOptionFromParent(command, key);
if (key === "wsLog") {
// wsLog has a child default ("auto"), so prefer inherited parent CLI value when present.
resolved[key] = inherited ?? resolved[key];
continue;
}
resolved[key] = resolved[key] ?? inherited;
}
for (const key of GATEWAY_RUN_BOOLEAN_KEYS) {
const inherited = inheritOptionFromParent<boolean>(command, key);
resolved[key] = Boolean(resolved[key] || inherited);
}
return resolved;
}
function resolveDevResetPaths(env: NodeJS.ProcessEnv = process.env): {
stateDir: string;
configPath: string;
defaultStateDir: string;
defaultConfigPath: string;
expectedDevStateDir: string;
expectedDevConfigPath: string;
} {
const home = resolveRequiredHomeDir(env, os.homedir);
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const defaultStateDir = path.join(home, ".openclaw");
const expectedDevStateDir = path.join(home, ".openclaw-dev");
return {
stateDir,
configPath,
defaultStateDir,
defaultConfigPath: path.join(defaultStateDir, "openclaw.json"),
expectedDevStateDir,
expectedDevConfigPath: path.join(expectedDevStateDir, "openclaw.json"),
};
}
async function runGatewayCommand(opts: GatewayRunOpts) {
const envProfile = process.env.OPENCLAW_PROFILE?.trim();
if (envProfile && !isValidProfileName(envProfile)) {
defaultRuntime.error(
'Invalid OPENCLAW_PROFILE (use letters, numbers, "_", "-" only, or unset the variable).',
);
defaultRuntime.exit(1);
return;
}
const isDevProfile = envProfile?.toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
if (opts.reset && !devMode) {
defaultRuntime.error("Use --reset with --dev.");
defaultRuntime.exit(1);
return;
}
if (opts.reset && devMode) {
const paths = resolveDevResetPaths(process.env);
const resolvedStateDir = path.resolve(paths.stateDir);
const resolvedConfigPath = path.resolve(paths.configPath);
const stateIsDefault = resolvedStateDir === path.resolve(paths.defaultStateDir);
const configIsDefault = resolvedConfigPath === path.resolve(paths.defaultConfigPath);
const stateMatchesDev = resolvedStateDir === path.resolve(paths.expectedDevStateDir);
const configMatchesDev = resolvedConfigPath === path.resolve(paths.expectedDevConfigPath);
const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim());
const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim());
const hasExplicitCustomTarget =
(hasStateOverride || hasConfigOverride) && !stateIsDefault && !configIsDefault;
if (!hasExplicitCustomTarget && (!stateMatchesDev || !configMatchesDev)) {
defaultRuntime.error(
[
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
`Resolved state dir: ${paths.stateDir}`,
`Resolved config path: ${paths.configPath}`,
`Expected dev state dir: ${paths.expectedDevStateDir}`,
`Expected dev config path: ${paths.expectedDevConfigPath}`,
"Retry with:",
" openclaw --dev gateway --dev --reset",
` OPENCLAW_STATE_DIR="${paths.expectedDevStateDir}" OPENCLAW_CONFIG_PATH="${paths.expectedDevConfigPath}" openclaw gateway --dev --reset`,
].join("\n"),
);
defaultRuntime.exit(1);
return;
}
}
setConsoleTimestampPrefix(true);
setVerbose(Boolean(opts.verbose));
if (opts.claudeCliLogs) {
setConsoleSubsystemFilter(["agent/claude-cli"]);
process.env.OPENCLAW_CLAUDE_CLI_LOG_OUTPUT = "1";
}
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as string | undefined;
const wsLogStyle: GatewayWsLogStyle =
wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
if (
wsLogRaw !== undefined &&
wsLogRaw !== "auto" &&
wsLogRaw !== "compact" &&
wsLogRaw !== "full"
) {
defaultRuntime.error('Invalid --ws-log (use "auto", "full", "compact")');
defaultRuntime.exit(1);
}
setGatewayWsLogStyle(wsLogStyle);
if (opts.rawStream) {
process.env.OPENCLAW_RAW_STREAM = "1";
}
const rawStreamPath = toOptionString(opts.rawStreamPath);
if (rawStreamPath) {
process.env.OPENCLAW_RAW_STREAM_PATH = rawStreamPath;
}
if (devMode) {
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
const bind =
bindRaw === "loopback" ||
bindRaw === "lan" ||
bindRaw === "auto" ||
bindRaw === "custom" ||
bindRaw === "tailnet"
? bindRaw
: null;
if (!bind) {
defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")');
defaultRuntime.exit(1);
return;
}
if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) {
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 { killed, waitedMs, escalatedToSigkill } = await forceFreePortAndWait(port, {
timeoutMs: 2000,
intervalMs: 100,
sigtermTimeoutMs: 700,
});
if (killed.length === 0) {
gatewayLog.info(`force: no listeners on port ${port}`);
} else {
for (const proc of killed) {
gatewayLog.info(
`force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
);
}
if (escalatedToSigkill) {
gatewayLog.info(`force: escalated to SIGKILL while freeing port ${port}`);
}
if (waitedMs > 0) {
gatewayLog.info(`force: waited ${waitedMs}ms for port ${port} to free`);
}
}
// After killing, verify the port is actually bindable (handles TIME_WAIT).
const bindProbeHost =
bind === "loopback"
? "127.0.0.1"
: bind === "lan"
? "0.0.0.0"
: bind === "custom"
? toOptionString(cfg.gateway?.customBindHost)
: undefined;
const bindWaitMs = await waitForPortBindable(port, {
timeoutMs: 3000,
intervalMs: 150,
host: bindProbeHost,
});
if (bindWaitMs > 0) {
gatewayLog.info(`force: waited ${bindWaitMs}ms for port ${port} to become bindable`);
}
} catch (err) {
defaultRuntime.error(`Force: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
}
if (opts.token) {
const token = toOptionString(opts.token);
if (token) {
process.env.OPENCLAW_GATEWAY_TOKEN = token;
}
}
const authModeRaw = toOptionString(opts.auth);
const authMode = parseEnumOption(authModeRaw, GATEWAY_AUTH_MODES);
if (authModeRaw && !authMode) {
defaultRuntime.error(`Invalid --auth (use ${formatModeErrorList(GATEWAY_AUTH_MODES)})`);
defaultRuntime.exit(1);
return;
}
const tailscaleRaw = toOptionString(opts.tailscale);
const tailscaleMode = parseEnumOption(tailscaleRaw, GATEWAY_TAILSCALE_MODES);
if (tailscaleRaw && !tailscaleMode) {
defaultRuntime.error(
`Invalid --tailscale (use ${formatModeErrorList(GATEWAY_TAILSCALE_MODES)})`,
);
defaultRuntime.exit(1);
return;
}
let passwordRaw: string | undefined;
try {
passwordRaw = resolveGatewayPasswordOption(opts);
} catch (err) {
defaultRuntime.error(err instanceof Error ? err.message : String(err));
defaultRuntime.exit(1);
return;
}
if (toOptionString(opts.password)) {
warnInlinePasswordFlag();
}
const tokenRaw = toOptionString(opts.token);
const snapshot = await readConfigFileSnapshot().catch(() => null);
const stateDir = resolveStateDir(process.env);
const configPath = resolveConfigPath(process.env, stateDir);
const configExists = snapshot?.exists ?? fs.existsSync(configPath);
const configAuditPath = path.join(stateDir, "logs", "config-audit.jsonl");
const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {
defaultRuntime.error(
`Missing config. Run \`${formatCliCommand("openclaw setup")}\` or set gateway.mode=local (or pass --allow-unconfigured).`,
);
} else {
defaultRuntime.error(
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
);
defaultRuntime.error(`Config write audit: ${configAuditPath}`);
}
defaultRuntime.exit(1);
return;
}
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
const authOverride =
authMode || passwordRaw || tokenRaw || authModeRaw
? {
...(authMode ? { mode: authMode } : {}),
...(tokenRaw ? { token: tokenRaw } : {}),
...(passwordRaw ? { password: passwordRaw } : {}),
}
: undefined;
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
authOverride,
env: process.env,
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
});
const resolvedAuthMode = resolvedAuth.mode;
const tokenValue = resolvedAuth.token;
const passwordValue = resolvedAuth.password;
const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0;
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
const tokenConfigured =
hasToken ||
hasConfiguredSecretInput(
authOverride?.token ?? cfg.gateway?.auth?.token,
cfg.secrets?.defaults,
);
const passwordConfigured =
hasPassword ||
hasConfiguredSecretInput(
authOverride?.password ?? cfg.gateway?.auth?.password,
cfg.secrets?.defaults,
);
const hasSharedSecret =
(resolvedAuthMode === "token" && tokenConfigured) ||
(resolvedAuthMode === "password" && passwordConfigured);
const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured;
const authHints: string[] = [];
if (miskeys.hasGatewayToken) {
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
}
if (miskeys.hasRemoteToken) {
authHints.push(
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
);
}
if (resolvedAuthMode === "password" && !passwordConfigured) {
defaultRuntime.error(
[
"Gateway auth is set to password, but no password is configured.",
"Set gateway.auth.password (or OPENCLAW_GATEWAY_PASSWORD), or pass --password.",
...authHints,
]
.filter(Boolean)
.join("\n"),
);
defaultRuntime.exit(1);
return;
}
if (resolvedAuthMode === "none") {
gatewayLog.warn(
"Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.",
);
}
if (
bind !== "loopback" &&
!hasSharedSecret &&
!canBootstrapToken &&
resolvedAuthMode !== "trusted-proxy"
) {
defaultRuntime.error(
[
`Refusing to bind gateway to ${bind} without auth.`,
"Set gateway.auth.token/password (or OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD) or pass --token/--password.",
...authHints,
]
.filter(Boolean)
.join("\n"),
);
defaultRuntime.exit(1);
return;
}
const tailscaleOverride =
tailscaleMode || opts.tailscaleResetOnExit
? {
...(tailscaleMode ? { mode: tailscaleMode } : {}),
...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}),
}
: undefined;
try {
await runGatewayLoop({
runtime: defaultRuntime,
lockPort: port,
start: async () =>
await startGatewayServer(port, {
bind,
auth: authOverride,
tailscale: tailscaleOverride,
}),
});
} catch (err) {
if (
err instanceof GatewayLockError ||
(err && typeof err === "object" && (err as { name?: string }).name === "GatewayLockError")
) {
const errMessage = describeUnknownError(err);
defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: ${formatCliCommand("openclaw gateway stop")}`,
);
try {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
for (const line of formatPortDiagnostics(diagnostics)) {
defaultRuntime.error(line);
}
}
} catch {
// ignore diagnostics failures
}
await maybeExplainGatewayServiceStop();
defaultRuntime.exit(1);
return;
}
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
defaultRuntime.exit(1);
}
}
export function addGatewayRunCommand(cmd: Command): Command {
return cmd
.option("--port <port>", "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 gateway.mode=local in config",
false,
)
.option("--dev", "Create a dev config + workspace if missing (no BOOTSTRAP.md)", false)
.option(
"--reset",
"Reset dev config + credentials + sessions + workspace (requires --dev)",
false,
)
.option("--force", "Kill any existing listener on the target port before starting", false)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.option(
"--claude-cli-logs",
"Only show claude-cli logs in the console (includes stdout/stderr)",
false,
)
.option("--ws-log <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));
});
}