UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,173 lines (1,172 loc) 46.7 kB
import { o as __toESM } from "./chunk-CNf5ZN-e.js"; import { u as redactToolPayloadText } from "./redact-DduLliKq.js"; import { o as isRecord$1 } from "./record-coerce-DHZ4bFlT.js"; import { y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { n as VERSION } from "./version-Crcn9X9T.js"; import { n as privateFileStoreSync } from "./private-file-store-BAvApZYp.js"; import { n as logError, t as logDebug } from "./logger-lqqYRtFw.js"; import { v as require_ipaddr } from "./ip-0oQXo6_w.js"; import { i as GATEWAY_CLIENT_NAMES, r as GATEWAY_CLIENT_MODES } from "./client-info-CcqJJIan.js"; import "./version-51ymduTn.js"; import { n as normalizeDeviceAuthScopes, t as normalizeDeviceAuthRole } from "./device-auth-C-STNejO.js"; import { a as publicKeyRawBase64UrlFromPem, o as signDevicePayload, r as loadOrCreateDeviceIdentity } from "./device-identity-CEPJolq9.js"; import { t as normalizeFingerprint$1 } from "./fingerprint-D5SzDQZa.js"; import { o as resolveSafeTimeoutDelayMs, r as resolveConnectChallengeTimeoutMs } from "./timeouts-DdTImbzl.js"; import { f as readPairingConnectErrorDetails, l as readConnectErrorDetailCode, s as formatConnectErrorMessage, t as ConnectErrorDetailCodes, u as readConnectErrorRecoveryAdvice } from "./connect-error-details-BXqba0zp.js"; import { a as resolveGatewayStartupRetryAfterMs } from "./startup-unavailable-CRTM-3cy.js"; import { r as registerManagedProxyGatewayLoopbackBypass, t as ensureInheritedManagedProxyRoutingActive } from "./proxy-lifecycle-Dv0tHumt.js"; import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { WebSocket as WebSocket$1 } from "ws"; //#region src/shared/device-auth-store.ts var import_ipaddr = /* @__PURE__ */ __toESM(require_ipaddr(), 1); function coerceDeviceAuthEntry(role, value) { if (!isRecord$1(value) || typeof value.token !== "string") return null; const updatedAtMs = typeof value.updatedAtMs === "number" && Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : 0; return { token: value.token, role, scopes: normalizeDeviceAuthScopes(Array.isArray(value.scopes) ? value.scopes : void 0), updatedAtMs }; } function copyCanonicalDeviceAuthTokens(tokens) { const out = {}; for (const [rawRole, value] of Object.entries(tokens)) { const role = normalizeDeviceAuthRole(rawRole); if (!role) continue; const entry = coerceDeviceAuthEntry(role, value); if (entry) out[role] = entry; } return out; } /** Coerces raw persisted device-auth JSON into the current canonical store shape. */ function coerceDeviceAuthStore(value) { if (!isRecord$1(value) || value.version !== 1 || typeof value.deviceId !== "string") return null; if (!isRecord$1(value.tokens)) return null; return { version: 1, deviceId: value.deviceId, tokens: copyCanonicalDeviceAuthTokens(value.tokens) }; } /** Load one normalized role token, ignoring stores bound to a different gateway device id. */ function loadDeviceAuthTokenFromStore(params) { const store = params.adapter.readStore(); if (!store || store.deviceId !== params.deviceId) return null; const role = normalizeDeviceAuthRole(params.role); return coerceDeviceAuthEntry(role, store.tokens[role]); } /** Store one role token while preserving canonical tokens for the same gateway device id. */ function storeDeviceAuthTokenInStore(params) { const role = normalizeDeviceAuthRole(params.role); const existing = params.adapter.readStore(); const next = { version: 1, deviceId: params.deviceId, tokens: existing && existing.deviceId === params.deviceId && existing.tokens ? copyCanonicalDeviceAuthTokens(existing.tokens) : {} }; const entry = { token: params.token, role, scopes: normalizeDeviceAuthScopes(params.scopes), updatedAtMs: Date.now() }; next.tokens[role] = entry; params.adapter.writeStore(next); return entry; } /** Clear one normalized role token without rewriting missing or wrong-device stores. */ function clearDeviceAuthTokenFromStore(params) { const store = params.adapter.readStore(); if (!store || store.deviceId !== params.deviceId) return; const role = normalizeDeviceAuthRole(params.role); if (!store.tokens[role]) return; const next = { version: 1, deviceId: store.deviceId, tokens: copyCanonicalDeviceAuthTokens(store.tokens) }; delete next.tokens[role]; params.adapter.writeStore(next); } //#endregion //#region src/infra/device-auth-store.ts const DEVICE_AUTH_FILE = "device-auth.json"; const storeReadCache = /* @__PURE__ */ new Map(); function storeCacheHit(cached, stat) { return cached !== void 0 && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size; } function resolveDeviceAuthPath(env = process.env) { return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE); } function readStore(filePath) { try { let stat = null; try { stat = fs.statSync(filePath); } catch { const cached = storeReadCache.get(filePath); if (cached?.mtimeMs === -1 && cached.size === -1) return cached.store; storeReadCache.set(filePath, { store: null, mtimeMs: -1, size: -1 }); return null; } const cached = storeReadCache.get(filePath); if (cached !== void 0 && storeCacheHit(cached, stat)) return cached.store; const store = coerceDeviceAuthStore(privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(path.basename(filePath))); storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size }); return store; } catch { return null; } } function writeStore(filePath, store) { privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), store, { trailingNewline: true }); try { const stat = fs.statSync(filePath); storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size }); } catch { storeReadCache.delete(filePath); } } /** Load a cached device-auth token from the configured OpenClaw state directory. */ function loadDeviceAuthToken(params) { const filePath = resolveDeviceAuthPath(params.env); return loadDeviceAuthTokenFromStore({ adapter: { readStore: () => readStore(filePath), writeStore: (_store) => {} }, deviceId: params.deviceId, role: params.role }); } /** Persist or replace one device-auth role token in the private state directory. */ function storeDeviceAuthToken(params) { const filePath = resolveDeviceAuthPath(params.env); return storeDeviceAuthTokenInStore({ adapter: { readStore: () => readStore(filePath), writeStore: (store) => writeStore(filePath, store) }, deviceId: params.deviceId, role: params.role, token: params.token, scopes: params.scopes }); } /** Remove one role token for the current gateway device from the private state directory. */ function clearDeviceAuthToken(params) { const filePath = resolveDeviceAuthPath(params.env); clearDeviceAuthTokenFromStore({ adapter: { readStore: () => readStore(filePath), writeStore: (store) => writeStore(filePath, store) }, deviceId: params.deviceId, role: params.role }); } //#endregion //#region packages/gateway-client/src/device-auth.ts function normalizeDeviceMetadataForAuth(value) { if (typeof value !== "string") return ""; const trimmed = value.trim(); if (!trimmed) return ""; return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); } function buildDeviceAuthPayload(params) { const scopes = params.scopes.join(","); const token = params.token ?? ""; return [ "v2", params.deviceId, params.clientId, params.clientMode, params.role, scopes, String(params.signedAtMs), token, params.nonce ].join("|"); } function buildDeviceAuthPayloadV3(params) { const scopes = params.scopes.join(","); const token = params.token ?? ""; const platform = normalizeDeviceMetadataForAuth(params.platform); const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily); return [ "v3", params.deviceId, params.clientId, params.clientMode, params.role, scopes, String(params.signedAtMs), token, params.nonce, platform, deviceFamily ].join("|"); } //#endregion //#region packages/gateway-client/src/client.ts function normalizeOptionalString(value) { if (typeof value !== "string") return; return value.trim() || void 0; } function isRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } function isNonNegativeInteger(value) { return typeof value === "number" && Number.isInteger(value) && value >= 0; } function isGatewayClientErrorShape(value) { if (!isRecord(value)) return false; if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) return false; if (value.retryable !== void 0 && typeof value.retryable !== "boolean") return false; if (value.retryAfterMs !== void 0 && !isNonNegativeInteger(value.retryAfterMs)) return false; return true; } function isGatewayEventFrame(value) { if (!isRecord(value) || value.type !== "event" || !isNonEmptyString(value.event)) return false; return value.seq === void 0 || isNonNegativeInteger(value.seq); } function isGatewayResponseFrame(value) { if (!isRecord(value) || value.type !== "res" || !isNonEmptyString(value.id) || typeof value.ok !== "boolean") return false; return value.error === void 0 || isGatewayClientErrorShape(value.error); } function validateClientRequestFrame(frame) { if (!isNonEmptyString(frame.id)) return "id must be a non-empty string"; if (!isNonEmptyString(frame.method)) return "method must be a non-empty string"; return null; } function normalizeLowercaseStringOrEmpty(value) { return typeof value === "string" ? value.trim().toLowerCase() : ""; } function rawDataToString(data) { if (typeof data === "string") return data; if (Buffer.isBuffer(data)) return data.toString("utf8"); if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); if (Array.isArray(data)) return Buffer.concat(data.map((entry) => Buffer.from(entry))).toString("utf8"); return String(data); } function isSensitiveUrlQueryParamName(key) { return /(?:token|password|secret|key|auth|credential)/iu.test(key); } function normalizeFingerprint(fingerprint) { return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase(); } function parseHostForAddressChecks(host) { if (!host) return null; const normalizedHost = host.toLowerCase().trim(); const canonicalHost = normalizedHost.replace(/\.+$/, ""); if (canonicalHost === "localhost") return { isLocalhost: true, unbracketedHost: canonicalHost }; return { isLocalhost: false, unbracketedHost: normalizedHost.startsWith("[") && normalizedHost.endsWith("]") ? normalizedHost.slice(1, -1) : normalizedHost }; } const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ "loopback", "private", "linkLocal", "carrierGradeNat" ]); const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "loopback", "linkLocal", "uniqueLocal", "deprecatedSiteLocal" ]); function parseGatewayIpAddress(host) { const normalized = host.toLowerCase(); if (import_ipaddr.default.IPv4.isValid(normalized) && !import_ipaddr.default.IPv4.isValidFourPartDecimal(normalized)) return null; if (!import_ipaddr.default.isValid(normalized)) return null; const parsed = import_ipaddr.default.parse(normalized); if (parsed.kind() === "ipv6") { const ipv6 = parsed; if (ipv6.isIPv4MappedAddress()) return ipv6.toIPv4Address(); } return parsed; } function isPrivateOrLoopbackIpAddress(address) { return (address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES).has(address.range()); } function isLoopbackHost(host) { const parsed = parseHostForAddressChecks(host); if (!parsed) return false; if (parsed.isLocalhost) return true; const address = parseGatewayIpAddress(parsed.unbracketedHost); if (!address) return false; return address.range() === "loopback"; } function isPrivateOrLoopbackHost(host) { const parsed = parseHostForAddressChecks(host); if (!parsed) return false; if (parsed.isLocalhost) return true; const address = parseGatewayIpAddress(parsed.unbracketedHost); if (!address) return false; return isPrivateOrLoopbackIpAddress(address); } function isTrustedPlaintextWebSocketHost(hostname) { if (isPrivateOrLoopbackHost(hostname)) return true; const normalized = hostname.toLowerCase().trim().replace(/\.+$/, ""); return normalized.endsWith(".local") || normalized.endsWith(".ts.net"); } function isSecureWebSocketUrl(rawUrl, options) { try { const url = new URL(rawUrl); const protocol = url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol; if (protocol === "wss:") return true; if (protocol !== "ws:") return false; if (isLoopbackHost(url.hostname) || isTrustedPlaintextWebSocketHost(url.hostname)) return true; if (options?.allowPrivateWs === true) { const hostForIpCheck = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname; return isPrivateOrLoopbackHost(url.hostname) || parseGatewayIpAddress(hostForIpCheck) === null; } return false; } catch { return false; } } const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789"; const DEFAULT_CLIENT_VERSION = "0.0.0"; var GatewayClientRequestError$1 = class extends Error { constructor(error) { super(formatConnectErrorMessage({ message: error.message, details: error.details })); this.name = "GatewayClientRequestError"; this.gatewayCode = error.code ?? "UNAVAILABLE"; this.details = error.details; this.retryable = error.retryable === true; this.retryAfterMs = error.retryAfterMs; } }; const GATEWAY_CONNECT_ASSEMBLY_ERROR = Symbol("gateway.connectAssemblyError"); function markGatewayConnectAssemblyError(error) { Object.defineProperty(error, GATEWAY_CONNECT_ASSEMBLY_ERROR, { configurable: true, value: true }); return error; } function isGatewayConnectAssemblyError$1(value) { return value instanceof Error && value[GATEWAY_CONNECT_ASSEMBLY_ERROR] === true; } const GATEWAY_CLOSE_CODE_HINTS$1 = { 1e3: "normal closure", 1006: "abnormal closure (no close frame)", 1008: "policy violation", 1012: "service restart", 1013: "try again later" }; function describeGatewayCloseCode$1(code) { return GATEWAY_CLOSE_CODE_HINTS$1[code]; } function readConnectChallengeTimeoutOverride(opts) { if (typeof opts.connectChallengeTimeoutMs === "number" && Number.isFinite(opts.connectChallengeTimeoutMs)) return opts.connectChallengeTimeoutMs; if (typeof opts.connectDelayMs === "number" && Number.isFinite(opts.connectDelayMs)) return opts.connectDelayMs; } function isGatewayClientStoppedError(err) { const message = err instanceof Error ? err.message : String(err); return message === "gateway client stopped" || message === "Error: gateway client stopped"; } function formatGatewayClientErrorForLog(err) { return String(err).replace(/\/\/([^@/?#\s]+)@/g, "//***:***@").replace(/(Authorization:\s*Bearer\s+)[^\s]+/giu, "$1***").replace(/([?&])([^=&\s]+)=([^&#\s"'<>)]*)/g, (match, prefix, key) => isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match); } function resolveGatewayClientConnectChallengeTimeoutMs$1(opts) { return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), { configuredTimeoutMs: opts.preauthHandshakeTimeoutMs }); } const FORCE_STOP_TERMINATE_GRACE_MS = 250; const STOP_AND_WAIT_TIMEOUT_MS = 1e3; var GatewayClient$1 = class { constructor(opts) { this.ws = null; this.pending = /* @__PURE__ */ new Map(); this.backoffMs = 1e3; this.closed = false; this.lastSeq = null; this.connectNonce = null; this.connectSent = false; this.connectTimer = null; this.reconnectTimer = null; this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; this.approvalRuntimeTokenCompatibilityDisabled = false; this.approvalRuntimeTokenRetryBudgetUsed = false; this.pendingStartupReconnectDelayMs = null; this.pendingConnectErrorDetailCode = null; this.pendingConnectErrorDetails = null; this.lastTick = null; this.tickIntervalMs = 3e4; this.tickTimer = null; this.pendingStop = null; this.socketOpened = false; this.deps = { loadOrCreateDeviceIdentity: opts.hostDeps?.loadOrCreateDeviceIdentity ?? (() => void 0), signDevicePayload: opts.hostDeps?.signDevicePayload ?? (() => { throw new Error("GatewayClient device signature dependency is not configured"); }), publicKeyRawBase64UrlFromPem: opts.hostDeps?.publicKeyRawBase64UrlFromPem ?? (() => { throw new Error("GatewayClient public key dependency is not configured"); }), loadDeviceAuthToken: opts.hostDeps?.loadDeviceAuthToken ?? (() => null), storeDeviceAuthToken: opts.hostDeps?.storeDeviceAuthToken ?? (() => {}), clearDeviceAuthToken: opts.hostDeps?.clearDeviceAuthToken ?? (() => {}), beforeConnect: opts.hostDeps?.beforeConnect ?? (() => {}), registerGatewayLoopbackBypass: opts.hostDeps?.registerGatewayLoopbackBypass ?? (() => void 0), logDebug: opts.hostDeps?.logDebug ?? (() => {}), logError: opts.hostDeps?.logError ?? (() => {}), redactForLog: opts.hostDeps?.redactForLog ?? ((message) => message), normalizeTlsFingerprint: opts.hostDeps?.normalizeTlsFingerprint ?? normalizeFingerprint }; this.opts = { ...opts, deviceIdentity: opts.deviceIdentity === null ? void 0 : opts.deviceIdentity ?? this.deps.loadOrCreateDeviceIdentity() }; this.requestTimeoutMs = typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs) ? resolveSafeTimeoutDelayMs(opts.requestTimeoutMs, { minMs: 0 }) : 3e4; } start() { if (this.closed) return; this.clearReconnectTimer(); this.clearConnectChallengeTimeout(); this.connectNonce = null; this.connectSent = false; const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL; if (this.opts.tlsFingerprint && !url.startsWith("wss://")) { this.notifyConnectError(/* @__PURE__ */ new Error("gateway tls fingerprint requires wss:// gateway url")); return; } const allowPrivateWs = (this.opts.env ?? process.env).OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { let displayHost = url; try { displayHost = new URL(url).hostname || url; } catch {} const error = /* @__PURE__ */ new Error(`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. Both credentials and chat data would be exposed to network interception. Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. ` + (allowPrivateWs ? "" : "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") + "Run `openclaw doctor --fix` for guidance."); this.notifyConnectError(error); return; } this.deps.beforeConnect(); const wsOptions = { maxPayload: 25 * 1024 * 1024 }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; wsOptions.checkServerIdentity = (_hostValue, cert) => { const fingerprintValue = typeof cert === "object" && cert && "fingerprint256" in cert ? cert.fingerprint256 ?? "" : ""; const fingerprint = this.deps.normalizeTlsFingerprint(typeof fingerprintValue === "string" ? fingerprintValue : ""); const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint ?? ""); if (!expected) return; if (!fingerprint) return /* @__PURE__ */ new Error("Missing server TLS fingerprint"); if (fingerprint !== expected) return /* @__PURE__ */ new Error("Server TLS fingerprint mismatch"); }; } let ws; const unregisterGatewayLoopbackBypass = this.deps.registerGatewayLoopbackBypass(url); try { ws = new WebSocket$1(url, wsOptions); } catch (error) { this.notifyConnectError(error instanceof Error ? error : new Error(String(error))); return; } finally { unregisterGatewayLoopbackBypass?.(); } this.ws = ws; this.socketOpened = false; this.connectNonce = null; this.connectSent = false; this.clearConnectChallengeTimeout(); ws.on("open", () => { this.socketOpened = true; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { const tlsError = this.validateTlsFingerprint(); if (tlsError) { this.notifyConnectError(tlsError); this.ws?.close(1008, tlsError.message); return; } } this.beginPreauthHandshake(); }); ws.on("message", (data) => this.handleMessage(rawDataToString(data))); ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); const connectErrorDetailCode = this.pendingConnectErrorDetailCode; const connectErrorDetails = this.pendingConnectErrorDetails; this.pendingConnectErrorDetailCode = null; this.pendingConnectErrorDetails = null; if (this.ws === ws) this.ws = null; this.socketOpened = false; this.resolvePendingStop(ws); if (this.pendingStartupReconnectDelayMs !== null) { this.scheduleReconnect(); return; } if (code === 1008 && normalizeLowercaseStringOrEmpty(reasonText).includes("device token mismatch") && !this.opts.token && !this.opts.password && this.opts.deviceIdentity) { const deviceId = this.opts.deviceIdentity.deviceId; const role = this.opts.role ?? "operator"; try { this.deps.clearDeviceAuthToken({ deviceId, role, env: this.opts.env }); this.logDebug(`cleared stale device-auth token for device ${deviceId}`); } catch (err) { this.logDebug(`failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`); } } this.flushPendingErrors(/* @__PURE__ */ new Error(`gateway closed (${code}): ${reasonText}`)); if (this.shouldPauseReconnectAfterAuthFailure({ detailCode: connectErrorDetailCode, details: connectErrorDetails })) { this.opts.onReconnectPaused?.({ code, reason: reasonText, detailCode: connectErrorDetailCode }); this.opts.onClose?.(code, reasonText); return; } this.scheduleReconnect(); this.opts.onClose?.(code, reasonText); }); ws.on("error", (err) => { this.logDebug(`gateway client error: ${formatGatewayClientErrorForLog(err)}`); if (!this.connectSent) this.notifyConnectError(err instanceof Error ? err : new Error(String(err))); }); } stop() { this.beginStop(); } async stopAndWait(opts) { const stopPromise = this.beginStop(); if (!stopPromise) return; const timeoutMs = opts?.timeoutMs === void 0 ? STOP_AND_WAIT_TIMEOUT_MS : resolveSafeTimeoutDelayMs(opts.timeoutMs); let timeout = null; try { await Promise.race([stopPromise, new Promise((_, reject) => { timeout = setTimeout(() => { reject(/* @__PURE__ */ new Error(`gateway client stop timed out after ${timeoutMs}ms`)); }, timeoutMs); timeout.unref?.(); })]); } finally { if (timeout) clearTimeout(timeout); } } beginStop() { this.closed = true; this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; this.pendingStartupReconnectDelayMs = null; this.pendingConnectErrorDetailCode = null; this.pendingConnectErrorDetails = null; this.clearReconnectTimer(); if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } this.clearConnectChallengeTimeout(); if (this.pendingStop) { this.flushPendingErrors(/* @__PURE__ */ new Error("gateway client stopped")); return this.pendingStop.promise; } const ws = this.ws; this.ws = null; if (ws) { const pendingStop = this.createPendingStop(ws); const forceTerminateTimer = setTimeout(() => { try { ws.terminate(); } catch {} this.resolvePendingStop(ws); }, FORCE_STOP_TERMINATE_GRACE_MS); forceTerminateTimer.unref?.(); pendingStop.terminateTimer = forceTerminateTimer; ws.close(); this.flushPendingErrors(/* @__PURE__ */ new Error("gateway client stopped")); return pendingStop.promise; } this.flushPendingErrors(/* @__PURE__ */ new Error("gateway client stopped")); return null; } createPendingStop(ws) { if (this.pendingStop?.ws === ws) return this.pendingStop; let resolve; const promise = new Promise((res) => { resolve = res; }); this.pendingStop = { ws, promise, resolve }; return this.pendingStop; } resolvePendingStop(ws) { if (this.pendingStop?.ws !== ws) return; const { resolve, terminateTimer } = this.pendingStop; if (terminateTimer) clearTimeout(terminateTimer); this.pendingStop = null; resolve(); } logDebug(message) { this.deps.logDebug(this.deps.redactForLog(message)); } logError(message) { this.deps.logError(this.deps.redactForLog(message)); } sendConnect() { if (this.connectSent) return; const nonce = normalizeOptionalString(this.connectNonce) ?? ""; if (!nonce) { this.notifyConnectError(/* @__PURE__ */ new Error("gateway connect challenge missing nonce")); this.ws?.close(1008, "connect challenge missing nonce"); return; } const role = this.opts.role ?? "operator"; let assembled; try { assembled = this.assembleConnectParams({ role, nonce }); } catch (err) { this.handleConnectFailure(err); return; } this.connectSent = true; this.clearConnectChallengeTimeout(); this.request("connect", assembled.params).then((helloOk) => { this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; this.pendingStartupReconnectDelayMs = null; this.pendingConnectErrorDetailCode = null; this.pendingConnectErrorDetails = null; const authInfo = helloOk?.auth; if (authInfo?.deviceToken && this.opts.deviceIdentity) this.deps.storeDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role: authInfo.role ?? role, token: authInfo.deviceToken, scopes: authInfo.scopes ?? [], env: this.opts.env }); this.backoffMs = 1e3; this.tickIntervalMs = typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 3e4; this.lastTick = Date.now(); this.startTickWatch(); this.opts.onHelloOk?.(helloOk); }).catch((err) => { this.pendingConnectErrorDetailCode = err instanceof GatewayClientRequestError$1 ? readConnectErrorDetailCode(err.details) : null; this.pendingConnectErrorDetails = err instanceof GatewayClientRequestError$1 ? err.details : null; const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({ error: err, explicitGatewayToken: normalizeOptionalString(this.opts.token), resolvedDeviceToken: assembled.resolvedDeviceToken, storedToken: assembled.storedToken }); if (this.opts.deviceIdentity && assembled.usingStoredDeviceToken && err instanceof GatewayClientRequestError$1 && readConnectErrorDetailCode(err.details) === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH) { const deviceId = this.opts.deviceIdentity.deviceId; try { this.deps.clearDeviceAuthToken({ deviceId, role, env: this.opts.env }); this.logDebug(`cleared stale device-auth token for device ${deviceId}`); } catch (clearErr) { this.logDebug(`failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`); } } if (shouldRetryWithDeviceToken) { this.pendingDeviceTokenRetry = true; this.deviceTokenRetryBudgetUsed = true; this.backoffMs = Math.min(this.backoffMs, 250); } const startupRetryAfterMs = resolveGatewayStartupRetryAfterMs(err); if (startupRetryAfterMs !== null) { this.pendingStartupReconnectDelayMs = startupRetryAfterMs; this.logDebug(`gateway connect failed: ${formatGatewayClientErrorForLog(err)}`); this.ws?.close(1013, "gateway starting"); return; } if (this.shouldRetryWithoutApprovalRuntimeToken({ error: err, authApprovalRuntimeToken: assembled.authApprovalRuntimeToken })) { this.approvalRuntimeTokenCompatibilityDisabled = true; this.approvalRuntimeTokenRetryBudgetUsed = true; this.backoffMs = Math.min(this.backoffMs, 250); this.logDebug("gateway rejected approval runtime auth field; retrying without it"); this.ws?.close(1008, "connect retry"); return; } this.notifyConnectError(err instanceof Error ? err : new Error(String(err))); const msg = `gateway connect failed: ${formatGatewayClientErrorForLog(err)}`; if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(err)) this.logDebug(msg); else this.logError(msg); this.ws?.close(1008, "connect failed"); }); } assembleConnectParams(params) { const { role, nonce } = params; const { authToken, authBootstrapToken, authDeviceToken, authPassword, authApprovalRuntimeToken, signatureToken, resolvedDeviceToken, storedToken, storedScopes, usingStoredDeviceToken } = this.selectConnectAuth(role); if (this.pendingDeviceTokenRetry && authDeviceToken) this.pendingDeviceTokenRetry = false; const auth = authToken || authBootstrapToken || authPassword || resolvedDeviceToken || authApprovalRuntimeToken ? { token: authToken, bootstrapToken: authBootstrapToken, deviceToken: authDeviceToken ?? resolvedDeviceToken, password: authPassword, approvalRuntimeToken: authApprovalRuntimeToken } : void 0; const signedAtMs = Date.now(); const scopes = this.resolveConnectScopes({ usingStoredDeviceToken, storedScopes }); const platform = this.opts.platform ?? process.platform; return { params: { minProtocol: this.opts.minProtocol ?? 4, maxProtocol: this.opts.maxProtocol ?? 4, client: { id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, displayName: this.opts.clientDisplayName, version: this.opts.clientVersion ?? DEFAULT_CLIENT_VERSION, platform, deviceFamily: this.opts.deviceFamily, mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, instanceId: this.opts.instanceId }, caps: Array.isArray(this.opts.caps) ? this.opts.caps : [], commands: Array.isArray(this.opts.commands) ? this.opts.commands : void 0, permissions: this.opts.permissions && typeof this.opts.permissions === "object" ? this.opts.permissions : void 0, pathEnv: this.opts.pathEnv, auth, role, scopes, device: this.buildDeviceConnectParams({ nonce, role, scopes, signatureToken, signedAtMs, platform }) }, authApprovalRuntimeToken, resolvedDeviceToken, storedToken, usingStoredDeviceToken }; } buildDeviceConnectParams(params) { if (!this.opts.deviceIdentity) return; const { nonce, role, scopes, signatureToken, signedAtMs, platform } = params; const payload = buildDeviceAuthPayloadV3({ deviceId: this.opts.deviceIdentity.deviceId, clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, role, scopes, signedAtMs, token: signatureToken ?? null, nonce, platform, deviceFamily: this.opts.deviceFamily }); const signature = this.deps.signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); return { id: this.opts.deviceIdentity.deviceId, publicKey: this.deps.publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem), signature, signedAt: signedAtMs, nonce }; } handleConnectFailure(err) { const error = err instanceof Error ? err : new Error(String(err)); this.clearConnectChallengeTimeout(); this.closed = true; this.notifyConnectError(markGatewayConnectAssemblyError(error)); const msg = `gateway connect failed: ${formatGatewayClientErrorForLog(error)}`; if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(error)) this.logDebug(msg); else this.logError(msg); this.ws?.close(1008, "connect failed"); } notifyConnectError(error) { try { this.opts.onConnectError?.(error); } catch (err) { this.logDebug(`gateway client connect error handler error: ${formatGatewayClientErrorForLog(err)}`); } } resolveConnectScopes(params) { if (Array.isArray(this.opts.scopes)) return this.opts.scopes; if (params.usingStoredDeviceToken && Array.isArray(params.storedScopes) && params.storedScopes.length > 0) return params.storedScopes; return this.opts.scopes ?? ["operator.admin"]; } loadStoredDeviceAuth(role) { if (!this.opts.deviceIdentity) return null; const storedAuth = this.deps.loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role, env: this.opts.env }); if (!storedAuth) return null; return { token: storedAuth.token, scopes: storedAuth.scopes }; } shouldPauseReconnectAfterAuthFailure(params) { const { detailCode, details } = params; if (!detailCode) return false; const pairingDetails = readPairingConnectErrorDetails(details); if (detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED && (pairingDetails?.pauseReconnect === false || pairingDetails?.recommendedNextStep === "wait_then_retry")) return false; if (detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || detailCode === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH || detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED || detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED || detailCode === ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH) return true; if (detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) return !this.pendingDeviceTokenRetry; return false; } shouldRetryWithStoredDeviceToken(params) { if (this.deviceTokenRetryBudgetUsed) return false; if (params.resolvedDeviceToken) return false; if (!params.explicitGatewayToken || !params.storedToken) return false; if (!this.isTrustedDeviceRetryEndpoint()) return false; if (!(params.error instanceof GatewayClientRequestError$1)) return false; const detailCode = readConnectErrorDetailCode(params.error.details); const advice = readConnectErrorRecoveryAdvice(params.error.details); const retryWithDeviceTokenRecommended = advice.recommendedNextStep === "retry_with_device_token"; return advice.canRetryWithDeviceToken === true || retryWithDeviceTokenRecommended || detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; } shouldRetryWithoutApprovalRuntimeToken(params) { if (this.approvalRuntimeTokenRetryBudgetUsed) return false; if (!params.authApprovalRuntimeToken) return false; if (!(params.error instanceof GatewayClientRequestError$1)) return false; if (params.error.gatewayCode !== "INVALID_REQUEST") return false; const message = normalizeLowercaseStringOrEmpty(params.error.message); return message.includes("invalid connect params") && message.includes("approvalruntimetoken"); } isTrustedDeviceRetryEndpoint() { const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789"; try { const parsed = new URL(rawUrl); const protocol = parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol; if (isLoopbackHost(parsed.hostname)) return true; return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim()); } catch { return false; } } selectConnectAuth(role) { const explicitGatewayToken = normalizeOptionalString(this.opts.token); const explicitBootstrapToken = normalizeOptionalString(this.opts.bootstrapToken); const explicitDeviceToken = normalizeOptionalString(this.opts.deviceToken); const authPassword = normalizeOptionalString(this.opts.password); const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled ? void 0 : normalizeOptionalString(this.opts.approvalRuntimeToken); const storedAuth = this.loadStoredDeviceAuth(role); const storedToken = storedAuth?.token ?? null; const storedScopes = storedAuth?.scopes; const shouldUseDeviceRetryToken = this.pendingDeviceTokenRetry && !explicitDeviceToken && Boolean(explicitGatewayToken) && Boolean(storedToken) && this.isTrustedDeviceRetryEndpoint(); const resolvedDeviceToken = explicitDeviceToken ?? (shouldUseDeviceRetryToken || !(explicitGatewayToken || authPassword) && (!explicitBootstrapToken || Boolean(storedToken)) ? storedToken ?? void 0 : void 0); const reusingStoredDeviceToken = Boolean(resolvedDeviceToken) && !explicitDeviceToken && Boolean(storedToken) && resolvedDeviceToken === storedToken; const authToken = explicitGatewayToken ?? resolvedDeviceToken; const authBootstrapToken = !explicitGatewayToken && !resolvedDeviceToken && !authPassword ? explicitBootstrapToken : void 0; return { authToken, authBootstrapToken, authDeviceToken: shouldUseDeviceRetryToken ? storedToken ?? void 0 : void 0, authPassword, authApprovalRuntimeToken, signatureToken: authToken ?? authBootstrapToken ?? void 0, resolvedDeviceToken, storedToken: storedToken ?? void 0, storedScopes, usingStoredDeviceToken: reusingStoredDeviceToken }; } handleMessage(raw) { let parsed; try { parsed = JSON.parse(raw); } catch (err) { this.logDebug(`gateway client parse error: ${formatGatewayClientErrorForLog(err)}`); return; } if (isGatewayEventFrame(parsed)) { this.lastTick = Date.now(); const evt = parsed; if (evt.event === "connect.challenge") { const payload = evt.payload; const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; if (!nonce || nonce.trim().length === 0) { this.notifyConnectError(/* @__PURE__ */ new Error("gateway connect challenge missing nonce")); this.ws?.close(1008, "connect challenge missing nonce"); return; } this.connectNonce = nonce.trim(); if (this.socketOpened) this.sendConnect(); return; } try { const seq = typeof evt.seq === "number" ? evt.seq : null; if (seq !== null) { if (this.lastSeq !== null && seq > this.lastSeq + 1) this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); this.lastSeq = seq; } if (evt.event === "tick") this.lastTick = Date.now(); this.opts.onEvent?.(evt); } catch (err) { this.logDebug(`gateway client event handler error: ${formatGatewayClientErrorForLog(err)}`); } return; } if (isGatewayResponseFrame(parsed)) { this.lastTick = Date.now(); const pending = this.pending.get(parsed.id); if (!pending) return; const status = parsed.payload?.status; if (pending.expectFinal && status === "accepted") { if (!pending.acceptedNotified) { pending.acceptedNotified = true; try { pending.onAccepted?.(parsed.payload); } catch (err) { this.logDebug(`gateway client accepted callback error: ${formatGatewayClientErrorForLog(err)}`); } } return; } this.pending.delete(parsed.id); pending.cleanup?.(); if (parsed.ok) pending.resolve(parsed.payload); else pending.reject(new GatewayClientRequestError$1({ code: parsed.error?.code, message: parsed.error?.message ?? "unknown error", details: parsed.error?.details, retryable: parsed.error?.retryable, retryAfterMs: parsed.error?.retryAfterMs })); } } beginPreauthHandshake() { if (this.connectSent) return; if (this.connectNonce && !this.connectSent) { this.armConnectChallengeTimeout(); this.sendConnect(); return; } this.armConnectChallengeTimeout(); } clearConnectChallengeTimeout() { if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } } clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } armConnectChallengeTimeout() { const connectChallengeTimeoutMs = resolveGatewayClientConnectChallengeTimeoutMs$1(this.opts); const armedAt = Date.now(); this.clearConnectChallengeTimeout(); this.connectTimer = setTimeout(() => { if (this.connectSent || this.ws?.readyState !== WebSocket$1.OPEN) return; const elapsedMs = Date.now() - armedAt; this.notifyConnectError(/* @__PURE__ */ new Error(`gateway connect challenge timeout (waited ${elapsedMs}ms, limit ${connectChallengeTimeoutMs}ms)`)); this.ws?.close(1008, "connect challenge timeout"); }, connectChallengeTimeoutMs); } scheduleReconnect() { if (this.closed) return; if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } this.clearReconnectTimer(); const startupDelay = this.pendingStartupReconnectDelayMs; this.pendingStartupReconnectDelayMs = null; const delay = startupDelay ?? this.backoffMs; if (startupDelay === null) this.backoffMs = Math.min(this.backoffMs * 2, 3e4); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.start(); }, delay); } flushPendingErrors(err) { for (const [, p] of this.pending) { p.cleanup?.(); p.reject(err); } this.pending.clear(); } startTickWatch() { if (this.tickTimer) clearInterval(this.tickTimer); const rawMinInterval = this.opts.tickWatchMinIntervalMs; const minInterval = typeof rawMinInterval === "number" && Number.isFinite(rawMinInterval) ? Math.max(1, Math.min(3e4, rawMinInterval)) : 1e3; const interval = resolveSafeTimeoutDelayMs(Math.max(this.tickIntervalMs, minInterval)); this.tickTimer = setInterval(() => { if (this.closed) return; if (!this.lastTick) return; if (this.pending.size > 0) return; const gap = Date.now() - this.lastTick; const rawTimeoutMs = this.opts.tickWatchTimeoutMs; if (gap > (typeof rawTimeoutMs === "number" && Number.isFinite(rawTimeoutMs) ? Math.max(1, rawTimeoutMs) : this.tickIntervalMs * 2)) this.ws?.close(4e3, "tick timeout"); }, interval); } validateTlsFingerprint() { if (!this.opts.tlsFingerprint || !this.ws) return null; const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint); if (!expected) return /* @__PURE__ */ new Error("gateway tls fingerprint missing"); const socket = this.ws["_socket"]; if (!socket || typeof socket.getPeerCertificate !== "function") return /* @__PURE__ */ new Error("gateway tls fingerprint unavailable"); const cert = socket.getPeerCertificate(); const fingerprint = this.deps.normalizeTlsFingerprint(cert?.fingerprint256 ?? ""); if (!fingerprint) return /* @__PURE__ */ new Error("gateway tls fingerprint unavailable"); if (fingerprint !== expected) return /* @__PURE__ */ new Error("gateway tls fingerprint mismatch"); return null; } async request(method, params, opts) { if (!this.ws || this.ws.readyState !== WebSocket$1.OPEN) throw new Error("gateway not connected"); if (opts?.signal?.aborted) throw createGatewayRequestAbortError(method); const id = randomUUID(); const frame = { type: "req", id, method, params }; const requestFrameError = validateClientRequestFrame(frame); if (requestFrameError) throw new Error(`invalid request frame: ${requestFrameError}`); const expectFinal = opts?.expectFinal === true; const timeoutMs = opts?.timeoutMs === null ? null : typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? resolveSafeTimeoutDelayMs(opts.timeoutMs, { minMs: 0 }) : expectFinal ? null : this.requestTimeoutMs; const signal = opts?.signal; const p = new Promise((resolve, reject) => { const timeout = timeoutMs === null ? null : setTimeout(() => { const pending = this.pending.get(id); this.pending.delete(id); pending?.cleanup?.(); reject(/* @__PURE__ */ new Error(`gateway request timeout for ${method}`)); }, timeoutMs); const cleanup = () => { if (timeout) clearTimeout(timeout); if (signal && abortHandler) signal.removeEventListener("abort", abortHandler); }; const abortHandler = () => { const pending = this.pending.get(id); this.pending.delete(id); pending?.cleanup?.(); reject(createGatewayRequestAbortError(method)); }; this.pending.set(id, { resolve: (value) => resolve(value), reject, expectFinal, timeout, cleanup, onAccepted: opts?.onAccepted }); signal?.addEventListener("abort", abortHandler, { once: true }); }); this.ws.send(JSON.stringify(frame)); return p; } }; function createGatewayRequestAbortError(method) { const err = /* @__PURE__ */ new Error(`gateway request aborted for ${method}`); err.name = "AbortError"; return err; } //#endregion //#region src/gateway/client.ts const GATEWAY_CLOSE_CODE_HINTS = GATEWAY_CLOSE_CODE_HINTS$1; const GatewayClientRequestError = GatewayClientRequestError$1; function describeGatewayCloseCode(code) { return describeGatewayCloseCode$1(code); } function isGatewayConnectAssemblyError(value) { return isGatewayConnectAssemblyError$1(value); } function createOpenClawGatewayClientHostDeps(overrides) { return { loadOrCreateDeviceIdentity, signDevicePayload, publicKeyRawBase64UrlFromPem, loadDeviceAuthToken, storeDeviceAuthToken, clearDeviceAuthToken, beforeConnect: ensureInheritedManagedProxyRoutingActive, registerGatewayLoopbackBypass: registerManagedProxyGatewayLoopbackBypass, normalizeTlsFingerprint: (fingerprint) => normalizeFingerprint$1(fingerprint ?? ""), logDebug, logError, redactForLog: redactToolPayloadText, ...overrides }; } function resolveGatewayClientConnectChallengeTimeoutMs(opts) { return resolveGatewayClientConnectChallengeTimeoutMs$1(opts); } var GatewayClient = class { #client; constructor(opts) { this.#client = new GatewayClient$1({ ...opts, clientVersion: opts.clientVersion ?? VERSION, hostDeps: createOpenClawGatewayClientHostDeps(opts.hostDeps) }); } start() { this.#client.start(); } stop() { this.#client.stop(); } stopAndWait(opts) { return this.#client.stopAndWait(opts); } request(method, params, opts) { return this.#client.request(method, params, opts); } getConnectionMetadata() { const opts = this.#client.opts; return { clientName: opts.clientName, hasDeviceIdentity: Boolean(opts.deviceIdentity), mode: opts.mode, preauthHandshakeTimeoutMs: opts.preauthHandshakeTimeoutMs }; } }; //#endregion export { isGatewayConnectAssemblyError as a, buildDeviceAuthPayloadV3 as c, describeGatewayCloseCode as i, normalizeDeviceMetadataForAuth as l, GatewayClient as n, resolveGatewayClientConnectChallengeTimeoutMs as o, GatewayClientRequestError as r, buildDeviceAuthPayload as s, GATEWAY_CLOSE_CODE_HINTS as t, loadDeviceAuthToken as u };