import { randomUUID } from "node:crypto"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, type GatewayClientMode, type GatewayClientName, } from "@openclaw/gateway-protocol/client-info"; import { ConnectErrorDetailCodes, formatConnectErrorMessage, readConnectErrorDetailCode, } from "@openclaw/gateway-protocol/connect-error-details"; import type { ConnectParams, ErrorShape, EventFrame, HelloOk, } from "@openclaw/gateway-protocol/frame-guards"; import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; import { isLoopbackIpAddress, normalizeIpAddress, parseCanonicalIpAddress, type ParsedIpAddress, } from "@openclaw/net-policy/ip"; import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { buildGatewayConnectAuth, type GatewayConnectAuthSelection, resolveGatewayConnectScopes, selectGatewayConnectAuth, shouldRetryGatewayWithDeviceToken, } from "./connect-auth.js"; import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; import { GatewayProtocolClient, GatewayProtocolRequestError, type GatewayProtocolCloseContext, type GatewayProtocolRequestOptions, type GatewayProtocolSocket, type GatewayProtocolSocketHandlers, } from "./protocol-client.js"; import { shouldPauseGatewayReconnect } from "./reconnect-policy.js"; import { resolveConnectChallengeTimeoutMs, resolveSafeTimeoutDelayMs } from "./timeouts.js"; import { rawDataToString } from "./websocket-data.js"; export type DeviceIdentity = { deviceId: string; privateKeyPem: string; publicKeyPem: string; }; export type DeviceAuthTokenRecord = { token?: string; scopes?: string[]; }; // The package stays reusable by depending on host callbacks for OpenClaw-owned // state: device keys, token storage, proxy routing, logging, and TLS formatting. export type GatewayClientHostDeps = { loadOrCreateDeviceIdentity?: () => DeviceIdentity | undefined; signDevicePayload?: (privateKeyPem: string, payload: string) => string; publicKeyRawBase64UrlFromPem?: (publicKeyPem: string) => string; loadDeviceAuthToken?: (params: { deviceId: string; role: string; env?: NodeJS.ProcessEnv; }) => DeviceAuthTokenRecord | null; storeDeviceAuthToken?: (params: { deviceId: string; role: string; token: string; scopes: string[]; env?: NodeJS.ProcessEnv; }) => void; clearDeviceAuthToken?: (params: { deviceId: string; role: string; env?: NodeJS.ProcessEnv; }) => void; beforeConnect?: () => void; registerGatewayLoopbackBypass?: (url: string) => (() => void) | undefined; logDebug?: (message: string) => void; logError?: (message: string) => void; redactForLog?: (message: string) => string; normalizeTlsFingerprint?: (fingerprint: string | undefined) => string; }; const DEFAULT_HOST_DEPS: Required = { loadOrCreateDeviceIdentity: () => undefined, signDevicePayload: () => { throw new Error("GatewayClient device signature dependency is not configured"); }, publicKeyRawBase64UrlFromPem: () => { throw new Error("GatewayClient public key dependency is not configured"); }, loadDeviceAuthToken: () => null, storeDeviceAuthToken: () => {}, clearDeviceAuthToken: () => {}, beforeConnect: () => {}, registerGatewayLoopbackBypass: () => undefined, logDebug: () => {}, logError: () => {}, redactForLog: (message) => message, normalizeTlsFingerprint: normalizeFingerprint, }; function resolveHostDeps(overrides?: GatewayClientHostDeps): Required { return Object.fromEntries( Object.entries(DEFAULT_HOST_DEPS).map(([key, fallback]) => [ key, overrides?.[key as keyof GatewayClientHostDeps] ?? fallback, ]), ) as Required; } function normalizeLowercaseStringOrEmpty(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } function isSensitiveUrlQueryParamName(key: string): boolean { return /(?:token|password|secret|key|auth|credential)/iu.test(key); } function normalizeFingerprint(fingerprint: string | undefined): string { return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase(); } function parseHostForAddressChecks( host: string, ): { isLocalhost: boolean; unbracketedHost: string } | null { if (!host) { return null; } const normalizedHost = host.toLowerCase().trim(); const canonicalHost = normalizedHost.replace(/\.+$/, ""); if (canonicalHost === "localhost") { return { isLocalhost: true, unbracketedHost: canonicalHost }; } return { isLocalhost: false, // URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip // them before net.isIP so address checks do not fall back to hostname rules. 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: string): ParsedIpAddress | undefined { const normalized = normalizeIpAddress(host); return normalized ? parseCanonicalIpAddress(normalized) : undefined; } function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean { const ranges = address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES; return ranges.has(address.range()); } function isLoopbackHost(host: string): boolean { const parsed = parseHostForAddressChecks(host); if (!parsed) { return false; } if (parsed.isLocalhost) { return true; } return isLoopbackIpAddress(parsed.unbracketedHost); } function isPrivateOrLoopbackHost(host: string): boolean { 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: string): boolean { if (isPrivateOrLoopbackHost(hostname)) { return true; } const normalized = hostname.toLowerCase().trim().replace(/\.+$/, ""); // Plain ws:// is still useful for local discovery and Tailnet names. Public // hostnames must use wss:// unless the caller opts into the private break-glass. return normalized.endsWith(".local") || normalized.endsWith(".ts.net"); } function isSecureWebSocketUrl(rawUrl: string, options?: { allowPrivateWs?: boolean }): boolean { 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) === undefined ); } return false; } catch { return false; } } export type GatewayClientRequestOptions = GatewayProtocolRequestOptions; type AssembledConnect = { params: ConnectParams; authApprovalRuntimeToken: string | undefined; authAgentRuntimeIdentityToken: string | undefined; resolvedDeviceToken: string | undefined; storedToken: string | undefined; usingStoredDeviceToken: boolean | undefined; }; type FingerprintCheckingClientOptions = Omit & { checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined; }; const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789"; const DEFAULT_CLIENT_VERSION = "0.0.0"; export type GatewayReconnectPausedInfo = { code: number; reason: string; detailCode: string | null; }; export type GatewayClientCloseInfo = { phase: "pre-hello" | "post-hello"; socketOpened: boolean; transportValidated: boolean; transientPreHelloCleanClose: boolean; }; export class GatewayClientRequestError extends GatewayProtocolRequestError { constructor(error: Partial) { super({ ...error, message: formatConnectErrorMessage({ message: error.message, details: error.details }), }); this.name = "GatewayClientRequestError"; } } class GatewayClientTransientPreHelloCloseError extends Error { constructor() { super("gateway transient pre-hello clean close"); this.name = "GatewayClientTransientPreHelloCloseError"; } } class GatewayClientTransportPolicyError extends Error {} const GATEWAY_CONNECT_ASSEMBLY_ERROR = Symbol("gateway.connectAssemblyError"); type GatewayConnectAssemblyError = Error & { [GATEWAY_CONNECT_ASSEMBLY_ERROR]?: true; }; function markGatewayConnectAssemblyError(error: Error): Error { Object.defineProperty(error, GATEWAY_CONNECT_ASSEMBLY_ERROR, { configurable: true, value: true, }); return error; } export function isGatewayConnectAssemblyError(value: unknown): value is Error { return ( value instanceof Error && (value as GatewayConnectAssemblyError)[GATEWAY_CONNECT_ASSEMBLY_ERROR] === true ); } export type GatewayClientOptions = { url?: string; // ws://127.0.0.1:18789 origin?: string; connectChallengeTimeoutMs?: number; /** * Server-side pre-auth handshake budget. Config-derived local clients use * this to keep the connect-challenge watchdog aligned with the gateway. */ preauthHandshakeTimeoutMs?: number; tickWatchMinIntervalMs?: number; tickWatchTimeoutMs?: number; requestTimeoutMs?: number; token?: string; bootstrapToken?: string; deviceToken?: string; password?: string; approvalRuntimeToken?: string; agentRuntimeIdentityToken?: string; instanceId?: string; clientName?: GatewayClientName; clientDisplayName?: string; clientVersion?: string; platform?: string; deviceFamily?: string; mode?: GatewayClientMode; role?: string; scopes?: string[]; caps?: string[]; commands?: string[]; permissions?: Record; pathEnv?: string; env?: NodeJS.ProcessEnv; deviceIdentity?: DeviceIdentity | null; hostDeps?: GatewayClientHostDeps; minProtocol?: number; maxProtocol?: number; tlsFingerprint?: string; onEvent?: (evt: EventFrame) => void; onHelloOk?: (hello: HelloOk) => void; onConnectError?: (err: Error) => void; onReconnectPaused?: (info: GatewayReconnectPausedInfo) => void; onClose?: (code: number, reason: string, info?: GatewayClientCloseInfo) => void; onGap?: (info: { expected: number; received: number }) => void; }; export type GatewayClientConnectionMetadata = { clientName?: GatewayClientName; hasDeviceIdentity: boolean; mode?: GatewayClientMode; preauthHandshakeTimeoutMs?: number; }; function readConnectChallengeTimeoutOverride( opts: Pick, ): number | undefined { if ( typeof opts.connectChallengeTimeoutMs === "number" && Number.isFinite(opts.connectChallengeTimeoutMs) ) { return opts.connectChallengeTimeoutMs; } return undefined; } function isGatewayClientStoppedError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return message === "gateway client stopped" || message === "Error: gateway client stopped"; } function formatGatewayClientErrorForLog(err: unknown): string { const redactedUrlLikeString = String(err) .replace(/\/\/([^@/?#\s]+)@/g, "//***:***@") .replace(/(Authorization:\s*Bearer\s+)[^\s]+/giu, "$1***") .replace(/([?&])([^=&\s]+)=([^&#\s"'<>)]*)/g, (match, prefix: string, key: string) => isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match, ); return redactedUrlLikeString; } function resolveGatewayClientConnectChallengeTimeoutMs( opts: Pick< GatewayClientOptions, "connectChallengeTimeoutMs" | "env" | "preauthHandshakeTimeoutMs" >, ): number { return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), { env: opts.env, configuredTimeoutMs: opts.preauthHandshakeTimeoutMs, }); } const FORCE_STOP_TERMINATE_GRACE_MS = 250; const STOP_AND_WAIT_TIMEOUT_MS = 1_000; const MAX_SUPPRESSED_TRANSIENT_PRE_HELLO_CLEAN_CLOSES = 1; type PendingStop = { ws: WebSocket; promise: Promise; resolve: () => void; terminateTimer?: NodeJS.Timeout; }; export class GatewayClient { private readonly protocol: GatewayProtocolClient; private ws: WebSocket | null = null; private opts: GatewayClientOptions; private deps: Required; private stopped = false; private pendingDeviceTokenRetry = false; private deviceTokenRetryBudgetUsed = false; private approvalRuntimeTokenCompatibilityDisabled = false; private approvalRuntimeTokenRetryBudgetUsed = false; // Track last tick to detect silent stalls. private lastTick: number | null = null; private tickIntervalMs = 30_000; private tickTimer: NodeJS.Timeout | null = null; private readonly requestTimeoutMs: number; private pendingStop: PendingStop | null = null; private transportValidated = false; private suppressedTransientPreHelloCleanCloses = 0; constructor(opts: GatewayClientOptions) { // Defaults keep the package inert until device identity support is used. this.deps = resolveHostDeps(opts.hostDeps); this.opts = { ...opts, deviceIdentity: opts.deviceIdentity === null ? undefined : (opts.deviceIdentity ?? this.deps.loadOrCreateDeviceIdentity()), }; this.requestTimeoutMs = typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs) ? resolveSafeTimeoutDelayMs(opts.requestTimeoutMs, { minMs: 0 }) : 30_000; this.protocol = new GatewayProtocolClient({ createSocket: (handlers) => this.createSocket(handlers), createRequestId: randomUUID, createRequestError: (error) => new GatewayClientRequestError(error), createRequestTimeoutError: (method) => new Error(`gateway request timeout for ${method}`), createRequestAbortError: createGatewayRequestAbortError, buildConnectPlan: ({ nonce }) => { if (!nonce) { throw new Error("gateway connect challenge missing nonce"); } return this.assembleConnectParams({ role: this.opts.role ?? "operator", nonce }); }, buildConnectParams: (assembled) => assembled.params, onConnectPlanError: (error) => { this.stopped = true; const marked = 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); } return { closeCode: 1008, closeReason: "connect failed", stop: true, error: marked }; }, onConnectHello: (hello, context) => this.handleConnectHello(hello, context.plan), onHello: (hello) => this.opts.onHelloOk?.(hello), onConnectFailure: (error, context) => this.handleConnectRequestFailure(error, context.plan), resolveClose: (context) => this.resolveClose(context), onClose: (context, decision) => { if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } if (decision.notify) { this.opts.onClose?.(context.code, context.reason, this.closeInfo(context)); } }, notifyStoppedClose: true, onConnectError: (error) => this.notifyConnectError(error), onParseError: (error) => this.logDebug(`gateway client parse error: ${formatGatewayClientErrorForLog(error)}`), onEvent: (event) => this.opts.onEvent?.(event), onGap: (info) => this.opts.onGap?.(info), onActivity: () => { this.lastTick = Date.now(); }, onCallbackError: (label, error) => this.logDebug( `gateway client ${label === "hello" ? "hello-ok" : label === "gap" ? "event" : label} handler error: ${formatGatewayClientErrorForLog(error)}`, ), handshake: { mode: "require-challenge", timeoutMs: resolveGatewayClientConnectChallengeTimeoutMs(this.opts), timeoutMessage: (elapsedMs) => `gateway connect challenge timeout (waited ${elapsedMs}ms, limit ${resolveGatewayClientConnectChallengeTimeoutMs(this.opts)}ms)`, }, reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }, requestTimeoutMs: this.requestTimeoutMs, rethrowSocketFactoryError: (error) => error instanceof GatewayClientTransportPolicyError, }); } getConnectionMetadata(): GatewayClientConnectionMetadata { return { clientName: this.opts.clientName, hasDeviceIdentity: Boolean(this.opts.deviceIdentity), mode: this.opts.mode, preauthHandshakeTimeoutMs: this.opts.preauthHandshakeTimeoutMs, }; } start() { if (this.stopped) { return; } this.protocol.start(); } private createSocket(handlers: GatewayProtocolSocketHandlers): GatewayProtocolSocket { const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL; if (this.opts.tlsFingerprint && !url.startsWith("wss://")) { throw new Error("gateway tls fingerprint requires wss:// gateway url"); } const allowPrivateWs = (this.opts.env ?? process.env).OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; // Block plaintext before device-token lookup. Credentials may be loaded from // host storage later in sendConnect(), and chat payloads are sensitive too. if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { // Safe hostname extraction - avoid throwing on malformed URLs in error path let displayHost = url; try { displayHost = new URL(url).hostname || url; } catch { // Use raw URL if parsing fails } throw 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.", ); } // Allow node screen snapshots and other large responses. this.deps.beforeConnect(); const wsOptions: FingerprintCheckingClientOptions = { maxPayload: 25 * 1024 * 1024, ...(this.opts.origin ? { origin: this.opts.origin } : {}), }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; wsOptions.checkServerIdentity = (_hostValue: string, cert: CertMeta) => { const fingerprintValue = typeof cert === "object" && cert && "fingerprint256" in cert ? ((cert as { fingerprint256?: string }).fingerprint256 ?? "") : ""; const fingerprint = this.deps.normalizeTlsFingerprint( typeof fingerprintValue === "string" ? fingerprintValue : "", ); const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint ?? ""); if (!expected) { return undefined; } if (!fingerprint) { return new Error("Missing server TLS fingerprint"); } if (fingerprint !== expected) { return new Error("Server TLS fingerprint mismatch"); } return undefined; }; } let ws: WebSocket; // Managed proxies can intercept local traffic; the host owns the bypass // lifecycle and must remove it immediately after the socket is created. let unregisterGatewayLoopbackBypass: (() => void) | undefined; try { unregisterGatewayLoopbackBypass = this.deps.registerGatewayLoopbackBypass(url); } catch (error) { throw new GatewayClientTransportPolicyError( error instanceof Error ? error.message : String(error), ); } try { ws = new WebSocket(url, wsOptions as ClientOptions); ws.binaryType = "nodebuffer"; } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } finally { unregisterGatewayLoopbackBypass?.(); } this.ws = ws; this.transportValidated = false; ws.on("open", () => { handlers.open(); if (url.startsWith("wss://") && this.opts.tlsFingerprint) { const tlsError = this.validateTlsFingerprint(); if (tlsError) { handlers.error(tlsError); ws.close(1008, tlsError.message); return; } } this.transportValidated = true; }); ws.on("message", (data) => handlers.message(rawDataToString(data))); ws.on("close", (code, reason) => { const reasonText = reason.toString(); if (this.ws === ws) { this.ws = null; } this.resolvePendingStop(ws); handlers.close(code, reasonText); }); ws.on("error", (err) => { this.logDebug(`gateway client error: ${formatGatewayClientErrorForLog(err)}`); handlers.error(err instanceof Error ? err : new Error(String(err))); }); return { isOpen: () => ws.readyState === WebSocket.OPEN, send: (data) => ws.send(data), close: (code, reason) => ws.close(code, reason), }; } stop() { void this.beginStop(); } async stopAndWait(opts?: { timeoutMs?: number }): Promise { // Some callers need teardown ordering, not just "close requested". Wait for // the socket to close or the terminate fallback to fire. const stopPromise = this.beginStop(); if (!stopPromise) { return; } const timeoutMs = opts?.timeoutMs === undefined ? STOP_AND_WAIT_TIMEOUT_MS : resolveSafeTimeoutDelayMs(opts.timeoutMs); let timeout: NodeJS.Timeout | null = null; try { await Promise.race([ stopPromise, new Promise((_, reject) => { timeout = setTimeout(() => { reject(new Error(`gateway client stop timed out after ${timeoutMs}ms`)); }, timeoutMs); timeout.unref?.(); }), ]); } finally { if (timeout) { clearTimeout(timeout); } } } private beginStop(): Promise | null { this.stopped = true; this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } if (this.pendingStop) { return this.pendingStop.promise; } const ws = this.ws; this.ws = null; if (ws) { const pendingStop = this.createPendingStop(ws); const forceTerminateTimer = setTimeout(() => { try { ws.terminate(); } finally { this.resolvePendingStop(ws); } }, FORCE_STOP_TERMINATE_GRACE_MS); forceTerminateTimer.unref?.(); pendingStop.terminateTimer = forceTerminateTimer; if (this.protocol.connecting) { const error = new Error("gateway client stopped"); this.notifyConnectError(error); this.logDebug(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`); } this.protocol.stop(); return pendingStop.promise; } this.protocol.stop(); return null; } private createPendingStop(ws: WebSocket): PendingStop { if (this.pendingStop?.ws === ws) { return this.pendingStop; } let resolve = () => {}; const promise = new Promise((done) => { resolve = done; }); this.pendingStop = { ws, promise, resolve }; return this.pendingStop; } private resolvePendingStop(ws: WebSocket): void { if (this.pendingStop?.ws !== ws) { return; } const { resolve, terminateTimer } = this.pendingStop; if (terminateTimer) { clearTimeout(terminateTimer); } this.pendingStop = null; resolve(); } private logDebug(message: string): void { this.deps.logDebug(this.deps.redactForLog(message)); } private logError(message: string): void { this.deps.logError(this.deps.redactForLog(message)); } private assembleConnectParams(params: { role: string; nonce: string }): AssembledConnect { const { role, nonce } = params; // Auth selection is intentionally centralized: retry decisions depend on // whether a token was explicit, cached, or compatibility-derived. const selectedAuth = this.selectConnectAuth(role); const { authDeviceToken, authApprovalRuntimeToken, authAgentRuntimeIdentityToken, signatureToken, resolvedDeviceToken, storedToken, storedScopes, usingStoredDeviceToken, } = selectedAuth; if (this.pendingDeviceTokenRetry && authDeviceToken) { this.pendingDeviceTokenRetry = false; } const auth = buildGatewayConnectAuth(selectedAuth); const signedAtMs = Date.now(); const scopes = resolveGatewayConnectScopes({ requestedScopes: this.opts.scopes, usingStoredDeviceToken, storedScopes, defaultScopes: ["operator.admin"], }); const platform = this.opts.platform ?? process.platform; return { params: { minProtocol: this.opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, 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 : undefined, permissions: this.opts.permissions && typeof this.opts.permissions === "object" ? this.opts.permissions : undefined, pathEnv: this.opts.pathEnv, auth, role, scopes, device: this.buildDeviceConnectParams({ nonce, role, scopes, signatureToken, signedAtMs, platform, }), }, authApprovalRuntimeToken, authAgentRuntimeIdentityToken, resolvedDeviceToken, storedToken, usingStoredDeviceToken, }; } private buildDeviceConnectParams(params: { nonce: string; role: string; scopes: string[]; signatureToken: string | undefined; signedAtMs: number; platform: string; }): ConnectParams["device"] { if (!this.opts.deviceIdentity) { return undefined; } const { nonce, role, scopes, signatureToken, signedAtMs, platform } = params; // The signed payload mirrors server verification exactly; keep metadata // normalized here so different hosts sign the same logical device facts. 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, }; } private handleConnectHello(helloOk: HelloOk, assembled: AssembledConnect): void { this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; this.suppressedTransientPreHelloCleanCloses = 0; const role = this.opts.role ?? "operator"; 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.tickIntervalMs = typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 30_000; this.lastTick = Date.now(); this.startTickWatch(); void assembled; } private handleConnectRequestFailure( error: GatewayProtocolRequestError, assembled: AssembledConnect, ) { const role = this.opts.role ?? "operator"; const shouldRetryWithDeviceToken = shouldRetryGatewayWithDeviceToken({ retryBudgetUsed: this.deviceTokenRetryBudgetUsed, currentDeviceToken: assembled.resolvedDeviceToken, explicitToken: this.opts.token?.trim() || undefined, storedToken: assembled.storedToken, trustedEndpoint: this.isTrustedDeviceRetryEndpoint(), errorDetails: error instanceof GatewayClientRequestError ? error.details : undefined, }); if ( this.opts.deviceIdentity && assembled.usingStoredDeviceToken && error instanceof GatewayClientRequestError && readConnectErrorDetailCode(error.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 (clearError) { this.logDebug( `failed clearing stale device-auth token for device ${deviceId}: ${String(clearError)}`, ); } } if (shouldRetryWithDeviceToken) { this.pendingDeviceTokenRetry = true; this.deviceTokenRetryBudgetUsed = true; this.protocol.resetReconnectBackoff(250); } const startupRetryAfterMs = resolveGatewayStartupRetryAfterMs(error); if (startupRetryAfterMs !== null) { this.logDebug(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`); return { closeCode: 1013, closeReason: "gateway starting", reconnectDelayMs: startupRetryAfterMs, }; } if ( this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({ error, authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken, }) ) { const unsupportedIdentityError = new Error( "gateway rejected required agent runtime identity auth field; refusing to retry without it", ); this.stopped = true; this.notifyConnectError(unsupportedIdentityError); this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`); return { closeCode: 1008, closeReason: "connect failed", stop: true }; } if ( this.shouldRetryWithoutApprovalRuntimeToken({ error, authApprovalRuntimeToken: assembled.authApprovalRuntimeToken, }) ) { this.approvalRuntimeTokenCompatibilityDisabled = true; this.approvalRuntimeTokenRetryBudgetUsed = true; this.protocol.resetReconnectBackoff(250); this.logDebug("gateway rejected approval runtime auth field; retrying without it"); return { closeCode: 1008, closeReason: "connect retry" }; } this.notifyConnectError(error); const message = `gateway connect failed: ${formatGatewayClientErrorForLog(error)}`; if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(error)) { this.logDebug(message); } else { this.logError(message); } return { closeCode: 1008, closeReason: "connect failed", }; } private resolveClose(context: GatewayProtocolCloseContext) { const info = this.closeInfo(context); const detailCode = context.connectFailure?.error instanceof GatewayClientRequestError ? readConnectErrorDetailCode(context.connectFailure.error.details) : null; const details = context.connectFailure?.error instanceof GatewayClientRequestError ? context.connectFailure.error.details : undefined; if (context.code === 1013 && context.connectFailure?.reconnectDelayMs !== undefined) { return { retry: true, notify: false, reconnectDelayMs: context.connectFailure.reconnectDelayMs, }; } if ( info.transientPreHelloCleanClose && this.suppressedTransientPreHelloCleanCloses < MAX_SUPPRESSED_TRANSIENT_PRE_HELLO_CLEAN_CLOSES ) { this.suppressedTransientPreHelloCleanCloses += 1; return { retry: true, notify: true, pendingError: new GatewayClientTransientPreHelloCloseError(), }; } if ( info.transientPreHelloCleanClose || (context.connectRequestSent && !context.helloReceived && !context.connectFailure) ) { const error = new Error(`gateway closed (${context.code}): ${context.reason}`); this.notifyConnectError(error); this.logError(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`); } this.clearStaleDeviceTokenForClose(context.code, context.reason); if ( shouldPauseGatewayReconnect({ details, deviceTokenRetryPending: this.pendingDeviceTokenRetry, tokenMismatchIsTerminal: true, clientVersionMismatchIsTerminal: true, }) ) { this.notifyReconnectPaused({ code: context.code, reason: context.reason, detailCode }); return { retry: false, notify: true }; } return { retry: true, notify: true, reconnectDelayMs: context.connectFailure?.reconnectDelayMs, }; } private closeInfo(context: GatewayProtocolCloseContext): GatewayClientCloseInfo { return { phase: context.helloReceived ? "post-hello" : "pre-hello", socketOpened: context.socketOpened, transportValidated: this.transportValidated, transientPreHelloCleanClose: !context.helloReceived && context.code === 1000 && context.reason === "", }; } private clearStaleDeviceTokenForClose(code: number, reason: string): void { if ( code !== 1008 || !normalizeLowercaseStringOrEmpty(reason).includes("device token mismatch") || this.opts.token || this.opts.password || !this.opts.deviceIdentity ) { return; } 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 (error) { this.logDebug( `failed clearing stale device-auth token for device ${deviceId}: ${String(error)}`, ); } } private notifyConnectError(error: Error) { try { this.opts.onConnectError?.(error); } catch (err) { this.logDebug( `gateway client connect error handler error: ${formatGatewayClientErrorForLog(err)}`, ); } } private notifyReconnectPaused(info: GatewayReconnectPausedInfo): void { try { this.opts.onReconnectPaused?.(info); } catch (err) { this.logDebug( `gateway client reconnect paused handler error: ${formatGatewayClientErrorForLog(err)}`, ); } } private shouldRetryWithoutApprovalRuntimeToken(params: { error: unknown; authApprovalRuntimeToken?: string; }): boolean { if (this.approvalRuntimeTokenRetryBudgetUsed) { return false; } if (!params.authApprovalRuntimeToken) { return false; } if (!(params.error instanceof GatewayClientRequestError)) { 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"); } private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: { error: unknown; authAgentRuntimeIdentityToken?: string; }): boolean { if (!params.authAgentRuntimeIdentityToken) { return false; } if (!(params.error instanceof GatewayClientRequestError)) { return false; } if (params.error.gatewayCode !== "INVALID_REQUEST") { return false; } const message = normalizeLowercaseStringOrEmpty(params.error.message); return ( message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken") ); } private isTrustedDeviceRetryEndpoint(): boolean { 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; } } private selectConnectAuth(role: string): GatewayConnectAuthSelection { const storedAuth = this.opts.deviceIdentity ? this.deps.loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role, env: this.opts.env, }) : null; return selectGatewayConnectAuth({ token: this.opts.token, bootstrapToken: this.opts.bootstrapToken, deviceToken: this.opts.deviceToken, password: this.opts.password, approvalRuntimeToken: this.approvalRuntimeTokenCompatibilityDisabled ? undefined : this.opts.approvalRuntimeToken, agentRuntimeIdentityToken: this.opts.agentRuntimeIdentityToken, storedToken: storedAuth?.token, storedScopes: storedAuth?.scopes, pendingDeviceTokenRetry: this.pendingDeviceTokenRetry, trustedDeviceTokenRetry: this.isTrustedDeviceRetryEndpoint(), }); } private 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(30_000, rawMinInterval)) : 1000; const interval = resolveSafeTimeoutDelayMs(Math.max(this.tickIntervalMs, minInterval)); this.tickTimer = setInterval(() => { if (this.stopped) { return; } if (!this.lastTick) { return; } const allPendingRequestsHaveTimeouts = this.protocol.hasPendingRequests && !this.protocol.hasUnboundedPendingRequests; // Finite requests own their deadline. One unbounded request keeps the // transport watchdog active so a dead socket cannot strand it forever. if (allPendingRequestsHaveTimeouts) { return; } const gap = Date.now() - this.lastTick; const rawTimeoutMs = this.opts.tickWatchTimeoutMs; // Normal gateways use the server-advertised tick interval. Long-running // harness clients can widen the threshold without mutating internals. const timeoutMs = typeof rawTimeoutMs === "number" && Number.isFinite(rawTimeoutMs) ? Math.max(1, rawTimeoutMs) : this.tickIntervalMs * 2; if (gap > timeoutMs) { this.protocol.closeSocket(4000, "tick timeout"); } }, interval); } private validateTlsFingerprint(): Error | null { if (!this.opts.tlsFingerprint || !this.ws) { return null; } const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint); if (!expected) { return new Error("gateway tls fingerprint missing"); } const socket = ( this.ws as WebSocket & { _socket?: { getPeerCertificate?: () => { fingerprint256?: string } }; } )["_socket"]; if (!socket || typeof socket.getPeerCertificate !== "function") { return new Error("gateway tls fingerprint unavailable"); } const cert = socket.getPeerCertificate(); const fingerprint = this.deps.normalizeTlsFingerprint(cert?.fingerprint256 ?? ""); if (!fingerprint) { return new Error("gateway tls fingerprint unavailable"); } if (fingerprint !== expected) { return new Error("gateway tls fingerprint mismatch"); } return null; } async request>( method: string, params?: unknown, opts?: GatewayClientRequestOptions, ): Promise { 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; return this.protocol.request(method, params, { expectFinal, timeoutMs, signal: opts?.signal, onAccepted: opts?.onAccepted, }); } } function createGatewayRequestAbortError(method: string): Error { const err = new Error(`gateway request aborted for ${method}`); err.name = "AbortError"; return err; }