mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 19:40:40 +00:00
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import {
|
|
loadConfig,
|
|
resolveConfigPath,
|
|
resolveGatewayPort,
|
|
resolveStateDir,
|
|
} from "../config/config.js";
|
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
|
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
|
import {
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
type GatewayClientMode,
|
|
type GatewayClientName,
|
|
} from "../utils/message-channel.js";
|
|
import { GatewayClient } from "./client.js";
|
|
import {
|
|
CLI_DEFAULT_OPERATOR_SCOPES,
|
|
resolveLeastPrivilegeOperatorScopesForMethod,
|
|
type OperatorScope,
|
|
} from "./method-scopes.js";
|
|
import { isSecureWebSocketUrl } from "./net.js";
|
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
|
|
|
type CallGatewayBaseOptions = {
|
|
url?: string;
|
|
token?: string;
|
|
password?: string;
|
|
tlsFingerprint?: string;
|
|
config?: OpenClawConfig;
|
|
method: string;
|
|
params?: unknown;
|
|
expectFinal?: boolean;
|
|
timeoutMs?: number;
|
|
clientName?: GatewayClientName;
|
|
clientDisplayName?: string;
|
|
clientVersion?: string;
|
|
platform?: string;
|
|
mode?: GatewayClientMode;
|
|
instanceId?: string;
|
|
minProtocol?: number;
|
|
maxProtocol?: number;
|
|
/**
|
|
* Overrides the config path shown in connection error details.
|
|
* Does not affect config loading; callers still control auth via opts.token/password/env/config.
|
|
*/
|
|
configPath?: string;
|
|
};
|
|
|
|
export type CallGatewayScopedOptions = CallGatewayBaseOptions & {
|
|
scopes: OperatorScope[];
|
|
};
|
|
|
|
export type CallGatewayCliOptions = CallGatewayBaseOptions & {
|
|
scopes?: OperatorScope[];
|
|
};
|
|
|
|
export type CallGatewayOptions = CallGatewayBaseOptions & {
|
|
scopes?: OperatorScope[];
|
|
};
|
|
|
|
export type GatewayConnectionDetails = {
|
|
url: string;
|
|
urlSource: string;
|
|
bindDetail?: string;
|
|
remoteFallbackNote?: string;
|
|
message: string;
|
|
};
|
|
|
|
export type ExplicitGatewayAuth = {
|
|
token?: string;
|
|
password?: string;
|
|
};
|
|
|
|
export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): ExplicitGatewayAuth {
|
|
const token =
|
|
typeof opts?.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() : undefined;
|
|
const password =
|
|
typeof opts?.password === "string" && opts.password.trim().length > 0
|
|
? opts.password.trim()
|
|
: undefined;
|
|
return { token, password };
|
|
}
|
|
|
|
export function ensureExplicitGatewayAuth(params: {
|
|
urlOverride?: string;
|
|
auth: ExplicitGatewayAuth;
|
|
errorHint: string;
|
|
configPath?: string;
|
|
}): void {
|
|
if (!params.urlOverride) {
|
|
return;
|
|
}
|
|
if (params.auth.token || params.auth.password) {
|
|
return;
|
|
}
|
|
const message = [
|
|
"gateway url override requires explicit credentials",
|
|
params.errorHint,
|
|
params.configPath ? `Config: ${params.configPath}` : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
throw new Error(message);
|
|
}
|
|
|
|
export function buildGatewayConnectionDetails(
|
|
options: { config?: OpenClawConfig; url?: string; configPath?: string } = {},
|
|
): GatewayConnectionDetails {
|
|
const config = options.config ?? loadConfig();
|
|
const configPath =
|
|
options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env));
|
|
const isRemoteMode = config.gateway?.mode === "remote";
|
|
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
|
const tlsEnabled = config.gateway?.tls?.enabled === true;
|
|
const localPort = resolveGatewayPort(config);
|
|
const bindMode = config.gateway?.bind ?? "loopback";
|
|
const scheme = tlsEnabled ? "wss" : "ws";
|
|
// Self-connections should always target loopback; bind mode only controls listener exposure.
|
|
const localUrl = `${scheme}://127.0.0.1:${localPort}`;
|
|
const urlOverride =
|
|
typeof options.url === "string" && options.url.trim().length > 0
|
|
? options.url.trim()
|
|
: undefined;
|
|
const remoteUrl =
|
|
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
|
|
const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl;
|
|
const url = urlOverride || remoteUrl || localUrl;
|
|
const urlSource = urlOverride
|
|
? "cli --url"
|
|
: remoteUrl
|
|
? "config gateway.remote.url"
|
|
: remoteMisconfigured
|
|
? "missing gateway.remote.url (fallback local)"
|
|
: "local loopback";
|
|
const remoteFallbackNote = remoteMisconfigured
|
|
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
|
|
: undefined;
|
|
const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
|
|
|
|
// Security check: block ALL insecure ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
|
|
// This applies to the FINAL resolved URL, regardless of source (config, CLI override, etc).
|
|
// Both credentials and chat/conversation data must not be transmitted over plaintext to remote hosts.
|
|
if (!isSecureWebSocketUrl(url)) {
|
|
throw new Error(
|
|
[
|
|
`SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`,
|
|
"Both credentials and chat data would be exposed to network interception.",
|
|
`Source: ${urlSource}`,
|
|
`Config: ${configPath}`,
|
|
"Fix: Use wss:// for remote gateway URLs.",
|
|
"Safe remote access defaults:",
|
|
"- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)",
|
|
"- or use Tailscale Serve/Funnel for HTTPS remote access",
|
|
"Doctor: openclaw doctor --fix",
|
|
"Docs: https://docs.openclaw.ai/gateway/remote",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
const message = [
|
|
`Gateway target: ${url}`,
|
|
`Source: ${urlSource}`,
|
|
`Config: ${configPath}`,
|
|
bindDetail,
|
|
remoteFallbackNote,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return {
|
|
url,
|
|
urlSource,
|
|
bindDetail,
|
|
remoteFallbackNote,
|
|
message,
|
|
};
|
|
}
|
|
|
|
type GatewayRemoteSettings = {
|
|
url?: string;
|
|
token?: string;
|
|
password?: string;
|
|
tlsFingerprint?: string;
|
|
};
|
|
|
|
type ResolvedGatewayCallContext = {
|
|
config: OpenClawConfig;
|
|
configPath: string;
|
|
isRemoteMode: boolean;
|
|
remote?: GatewayRemoteSettings;
|
|
urlOverride?: string;
|
|
remoteUrl?: string;
|
|
explicitAuth: ExplicitGatewayAuth;
|
|
};
|
|
|
|
function trimToUndefined(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function resolveGatewayCallTimeout(timeoutValue: unknown): {
|
|
timeoutMs: number;
|
|
safeTimerTimeoutMs: number;
|
|
} {
|
|
const timeoutMs =
|
|
typeof timeoutValue === "number" && Number.isFinite(timeoutValue) ? timeoutValue : 10_000;
|
|
const safeTimerTimeoutMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647));
|
|
return { timeoutMs, safeTimerTimeoutMs };
|
|
}
|
|
|
|
function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewayCallContext {
|
|
const config = opts.config ?? loadConfig();
|
|
const configPath =
|
|
opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env));
|
|
const isRemoteMode = config.gateway?.mode === "remote";
|
|
const remote = isRemoteMode
|
|
? (config.gateway?.remote as GatewayRemoteSettings | undefined)
|
|
: undefined;
|
|
const urlOverride = trimToUndefined(opts.url);
|
|
const remoteUrl = trimToUndefined(remote?.url);
|
|
const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password });
|
|
return { config, configPath, isRemoteMode, remote, urlOverride, remoteUrl, explicitAuth };
|
|
}
|
|
|
|
function ensureRemoteModeUrlConfigured(context: ResolvedGatewayCallContext): void {
|
|
if (!context.isRemoteMode || context.urlOverride || context.remoteUrl) {
|
|
return;
|
|
}
|
|
throw new Error(
|
|
[
|
|
"gateway remote mode misconfigured: gateway.remote.url missing",
|
|
`Config: ${context.configPath}`,
|
|
"Fix: set gateway.remote.url, or set gateway.mode=local.",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function resolveGatewayCredentials(context: ResolvedGatewayCallContext): {
|
|
token?: string;
|
|
password?: string;
|
|
} {
|
|
const authToken = context.config.gateway?.auth?.token;
|
|
const authPassword = context.config.gateway?.auth?.password;
|
|
const token =
|
|
context.explicitAuth.token ||
|
|
(!context.urlOverride
|
|
? context.isRemoteMode
|
|
? trimToUndefined(context.remote?.token)
|
|
: trimToUndefined(process.env.OPENCLAW_GATEWAY_TOKEN) ||
|
|
trimToUndefined(process.env.CLAWDBOT_GATEWAY_TOKEN) ||
|
|
trimToUndefined(authToken)
|
|
: undefined);
|
|
const password =
|
|
context.explicitAuth.password ||
|
|
(!context.urlOverride
|
|
? trimToUndefined(process.env.OPENCLAW_GATEWAY_PASSWORD) ||
|
|
trimToUndefined(process.env.CLAWDBOT_GATEWAY_PASSWORD) ||
|
|
(context.isRemoteMode
|
|
? trimToUndefined(context.remote?.password)
|
|
: trimToUndefined(authPassword))
|
|
: undefined);
|
|
return { token, password };
|
|
}
|
|
|
|
async function resolveGatewayTlsFingerprint(params: {
|
|
opts: CallGatewayBaseOptions;
|
|
context: ResolvedGatewayCallContext;
|
|
url: string;
|
|
}): Promise<string | undefined> {
|
|
const { opts, context, url } = params;
|
|
const useLocalTls =
|
|
context.config.gateway?.tls?.enabled === true &&
|
|
!context.urlOverride &&
|
|
!context.remoteUrl &&
|
|
url.startsWith("wss://");
|
|
const tlsRuntime = useLocalTls
|
|
? await loadGatewayTlsRuntime(context.config.gateway?.tls)
|
|
: undefined;
|
|
const overrideTlsFingerprint = trimToUndefined(opts.tlsFingerprint);
|
|
const remoteTlsFingerprint =
|
|
context.isRemoteMode && !context.urlOverride && context.remoteUrl
|
|
? trimToUndefined(context.remote?.tlsFingerprint)
|
|
: undefined;
|
|
return (
|
|
overrideTlsFingerprint ||
|
|
remoteTlsFingerprint ||
|
|
(tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined)
|
|
);
|
|
}
|
|
|
|
function formatGatewayCloseError(
|
|
code: number,
|
|
reason: string,
|
|
connectionDetails: GatewayConnectionDetails,
|
|
): string {
|
|
const reasonText = reason?.trim() || "no close reason";
|
|
const hint =
|
|
code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : "";
|
|
const suffix = hint ? ` ${hint}` : "";
|
|
return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`;
|
|
}
|
|
|
|
function formatGatewayTimeoutError(
|
|
timeoutMs: number,
|
|
connectionDetails: GatewayConnectionDetails,
|
|
): string {
|
|
return `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`;
|
|
}
|
|
|
|
async function executeGatewayRequestWithScopes<T>(params: {
|
|
opts: CallGatewayBaseOptions;
|
|
scopes: OperatorScope[];
|
|
url: string;
|
|
token?: string;
|
|
password?: string;
|
|
tlsFingerprint?: string;
|
|
timeoutMs: number;
|
|
safeTimerTimeoutMs: number;
|
|
connectionDetails: GatewayConnectionDetails;
|
|
}): Promise<T> {
|
|
const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } =
|
|
params;
|
|
return await new Promise<T>((resolve, reject) => {
|
|
let settled = false;
|
|
let ignoreClose = false;
|
|
const stop = (err?: Error, value?: T) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve(value as T);
|
|
}
|
|
};
|
|
|
|
const client = new GatewayClient({
|
|
url,
|
|
token,
|
|
password,
|
|
tlsFingerprint,
|
|
instanceId: opts.instanceId ?? randomUUID(),
|
|
clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
|
|
clientDisplayName: opts.clientDisplayName,
|
|
clientVersion: opts.clientVersion ?? "dev",
|
|
platform: opts.platform,
|
|
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
|
role: "operator",
|
|
scopes,
|
|
deviceIdentity: loadOrCreateDeviceIdentity(),
|
|
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
|
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
|
onHelloOk: async () => {
|
|
try {
|
|
const result = await client.request<T>(opts.method, opts.params, {
|
|
expectFinal: opts.expectFinal,
|
|
});
|
|
ignoreClose = true;
|
|
stop(undefined, result);
|
|
client.stop();
|
|
} catch (err) {
|
|
ignoreClose = true;
|
|
client.stop();
|
|
stop(err as Error);
|
|
}
|
|
},
|
|
onClose: (code, reason) => {
|
|
if (settled || ignoreClose) {
|
|
return;
|
|
}
|
|
ignoreClose = true;
|
|
client.stop();
|
|
stop(new Error(formatGatewayCloseError(code, reason, params.connectionDetails)));
|
|
},
|
|
});
|
|
|
|
const timer = setTimeout(() => {
|
|
ignoreClose = true;
|
|
client.stop();
|
|
stop(new Error(formatGatewayTimeoutError(timeoutMs, params.connectionDetails)));
|
|
}, safeTimerTimeoutMs);
|
|
|
|
client.start();
|
|
});
|
|
}
|
|
|
|
async function callGatewayWithScopes<T = Record<string, unknown>>(
|
|
opts: CallGatewayBaseOptions,
|
|
scopes: OperatorScope[],
|
|
): Promise<T> {
|
|
const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs);
|
|
const context = resolveGatewayCallContext(opts);
|
|
ensureExplicitGatewayAuth({
|
|
urlOverride: context.urlOverride,
|
|
auth: context.explicitAuth,
|
|
errorHint: "Fix: pass --token or --password (or gatewayToken in tools).",
|
|
configPath: context.configPath,
|
|
});
|
|
ensureRemoteModeUrlConfigured(context);
|
|
const connectionDetails = buildGatewayConnectionDetails({
|
|
config: context.config,
|
|
url: context.urlOverride,
|
|
...(opts.configPath ? { configPath: opts.configPath } : {}),
|
|
});
|
|
const url = connectionDetails.url;
|
|
const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url });
|
|
const { token, password } = resolveGatewayCredentials(context);
|
|
return await executeGatewayRequestWithScopes<T>({
|
|
opts,
|
|
scopes,
|
|
url,
|
|
token,
|
|
password,
|
|
tlsFingerprint,
|
|
timeoutMs,
|
|
safeTimerTimeoutMs,
|
|
connectionDetails,
|
|
});
|
|
}
|
|
|
|
export async function callGatewayScoped<T = Record<string, unknown>>(
|
|
opts: CallGatewayScopedOptions,
|
|
): Promise<T> {
|
|
return await callGatewayWithScopes(opts, opts.scopes);
|
|
}
|
|
|
|
export async function callGatewayCli<T = Record<string, unknown>>(
|
|
opts: CallGatewayCliOptions,
|
|
): Promise<T> {
|
|
const scopes = Array.isArray(opts.scopes) ? opts.scopes : CLI_DEFAULT_OPERATOR_SCOPES;
|
|
return await callGatewayWithScopes(opts, scopes);
|
|
}
|
|
|
|
export async function callGatewayLeastPrivilege<T = Record<string, unknown>>(
|
|
opts: CallGatewayBaseOptions,
|
|
): Promise<T> {
|
|
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(opts.method);
|
|
return await callGatewayWithScopes(opts, scopes);
|
|
}
|
|
|
|
export async function callGateway<T = Record<string, unknown>>(
|
|
opts: CallGatewayOptions,
|
|
): Promise<T> {
|
|
if (Array.isArray(opts.scopes)) {
|
|
return await callGatewayWithScopes(opts, opts.scopes);
|
|
}
|
|
const callerMode = opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND;
|
|
const callerName = opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT;
|
|
if (callerMode === GATEWAY_CLIENT_MODES.CLI || callerName === GATEWAY_CLIENT_NAMES.CLI) {
|
|
return await callGatewayCli(opts);
|
|
}
|
|
return await callGatewayLeastPrivilege({
|
|
...opts,
|
|
mode: callerMode,
|
|
clientName: callerName,
|
|
});
|
|
}
|
|
|
|
export function randomIdempotencyKey() {
|
|
return randomUUID();
|
|
}
|