From b41fdc9284034274cad8170f9b1279efd42d6f1a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 12:21:58 -0500 Subject: [PATCH] Extract OpenClaw gateway client with auth fallback - Move gateway connection and handshake logic into a shared client - Add device-token auth persistence and retry support for Tailscale gateway tests - Simplify the gateway test to use the new client layer --- apps/server/src/openclawGatewayTest.ts | 491 ++--------- .../provider/Layers/OpenClawGatewayClient.ts | 781 ++++++++++++++++++ 2 files changed, 841 insertions(+), 431 deletions(-) create mode 100644 apps/server/src/provider/Layers/OpenClawGatewayClient.ts diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index c33707d63..f1a98059d 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -9,30 +9,16 @@ import type { TestOpenclawGatewayStep, TestOpenclawGatewayStepStatus, } from "@okcode/contracts"; -import NodeWebSocket from "ws"; import { serverBuildInfo } from "./buildInfo.ts"; +import { connectOpenClawGateway } from "./provider/Layers/OpenClawGatewayClient.ts"; const OPENCLAW_TEST_CONNECT_TIMEOUT_MS = 10_000; const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000; const OPENCLAW_TEST_HEALTH_TIMEOUT_MS = 2_500; const OPENCLAW_TEST_LOOKUP_TIMEOUT_MS = 1_500; const MAX_CAPTURED_NOTIFICATIONS = 5; -const OPENCLAW_PROTOCOL_VERSION = 3; const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; -type GatewayEnvelope = { - type?: unknown; - id?: unknown; - ok?: unknown; - event?: unknown; - payload?: unknown; - error?: { - code?: unknown; - message?: unknown; - details?: unknown; - }; -}; - interface GatewayHealthProbe { status: TestOpenclawGatewayStepStatus; url?: string; @@ -60,13 +46,10 @@ interface MutableGatewayDiagnostics { hints: string[]; } -interface ParsedGatewayError { - message: string; - code?: string; - detailCode?: string; - detailReason?: string; - recommendedNextStep?: string; - canRetryWithDeviceToken?: boolean; +interface OpenClawGatewayErrorLike { + readonly message: string; + readonly code?: string; + readonly details?: Record; } function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { @@ -92,110 +75,29 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function bufferToString(data: NodeWebSocket.Data): string { - if (typeof data === "string") return data; - if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - return data.toString("utf8"); -} - -function parseGatewayEnvelope(data: NodeWebSocket.Data): GatewayEnvelope | null { - try { - const parsed = JSON.parse(bufferToString(data)); - if (typeof parsed === "object" && parsed !== null) { - return parsed as GatewayEnvelope; - } - } catch { - // Ignore non-JSON websocket messages from intermediaries. - } - return null; -} - function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function parseGatewayError(error: GatewayEnvelope["error"]): ParsedGatewayError { - const details = - typeof error?.details === "object" && error.details !== null - ? (error.details as Record) - : undefined; - const parsed: ParsedGatewayError = { - message: readString(error?.message) ?? "Gateway request failed.", - }; - const code = - typeof error?.code === "string" || typeof error?.code === "number" - ? String(error.code) - : undefined; - const detailCode = readString(details?.code); - const detailReason = readString(details?.reason); - const recommendedNextStep = readString(details?.recommendedNextStep); - const canRetryWithDeviceToken = readBoolean(details?.canRetryWithDeviceToken); - - if (code) { - parsed.code = code; - } - if (detailCode) { - parsed.detailCode = detailCode; - } - if (detailReason) { - parsed.detailReason = detailReason; - } - if (recommendedNextStep) { - parsed.recommendedNextStep = recommendedNextStep; - } - if (canRetryWithDeviceToken !== undefined) { - parsed.canRetryWithDeviceToken = canRetryWithDeviceToken; - } - - return parsed; -} - -function recordGatewayError( +function applyGatewayError( diagnostics: MutableGatewayDiagnostics, - error: ParsedGatewayError | undefined, + error: OpenClawGatewayErrorLike | undefined, ): void { - if (error?.code) { - diagnostics.gatewayErrorCode = error.code; - } else { - delete diagnostics.gatewayErrorCode; - } - if (error?.detailCode) { - diagnostics.gatewayErrorDetailCode = error.detailCode; - } else { - delete diagnostics.gatewayErrorDetailCode; - } - if (error?.detailReason) { - diagnostics.gatewayErrorDetailReason = error.detailReason; - } else { - delete diagnostics.gatewayErrorDetailReason; - } - if (error?.recommendedNextStep) { - diagnostics.gatewayRecommendedNextStep = error.recommendedNextStep; - } else { - delete diagnostics.gatewayRecommendedNextStep; - } - if (error?.canRetryWithDeviceToken !== undefined) { - diagnostics.gatewayCanRetryWithDeviceToken = error.canRetryWithDeviceToken; - } else { - delete diagnostics.gatewayCanRetryWithDeviceToken; - } -} - -function formatGatewayError(error: ParsedGatewayError): string { - const detailParts = [ - error.code ? `code ${error.code}` : null, - error.detailCode ? `detail ${error.detailCode}` : null, - error.detailReason ? `reason ${error.detailReason}` : null, - error.recommendedNextStep ? `next ${error.recommendedNextStep}` : null, - error.canRetryWithDeviceToken ? "device-token retry available" : null, - ].filter((part): part is string => part !== null); - - return detailParts.length > 0 ? `${error.message} (${detailParts.join(", ")})` : error.message; + if (!error) { + return; + } + + diagnostics.gatewayErrorCode = error.code; + const details = error.details ?? {}; + diagnostics.gatewayErrorDetailCode = typeof details.code === "string" ? details.code : undefined; + diagnostics.gatewayErrorDetailReason = + typeof details.reason === "string" ? details.reason : undefined; + diagnostics.gatewayRecommendedNextStep = + typeof details.recommendedNextStep === "string" ? details.recommendedNextStep : undefined; + diagnostics.gatewayCanRetryWithDeviceToken = + typeof details.canRetryWithDeviceToken === "boolean" + ? details.canRetryWithDeviceToken + : undefined; } function pushUnique(items: string[], value: string): void { @@ -348,21 +250,6 @@ function formatSocketClose(code: number | undefined, reason: string | undefined) return reason && reason.length > 0 ? `code ${code}: ${reason}` : `code ${code}`; } -function buildTimeoutDetail(subject: string, diagnostics: TestOpenclawGatewayDiagnostics): string { - const parts = [`${subject} timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms.`]; - const closeDetail = formatSocketClose(diagnostics.socketCloseCode, diagnostics.socketCloseReason); - if (closeDetail) { - parts.push(`Socket closed with ${closeDetail}.`); - } - if (diagnostics.socketError) { - parts.push(`Last socket error: ${diagnostics.socketError}.`); - } - if (diagnostics.observedNotifications.length > 0) { - parts.push(`Observed gateway events: ${diagnostics.observedNotifications.join(", ")}.`); - } - return parts.join(" "); -} - function buildHints( parsedUrl: URL, diagnostics: Pick< @@ -520,15 +407,12 @@ function createDiagnostics(): MutableGatewayDiagnostics { export async function runOpenclawGatewayTest( input: TestOpenclawGatewayInput, + options?: RunOpenclawGatewayTestOptions, ): Promise { const overallStart = Date.now(); const steps: TestOpenclawGatewayStep[] = []; - let ws: NodeWebSocket | null = null; - let rpcId = 1; - const serverInfo: { version?: string; sessionId?: string } = {}; const diagnostics: MutableGatewayDiagnostics = createDiagnostics(); - const earlyGatewayEvents: GatewayEnvelope[] = []; - let captureEarlyGatewayEvents = true; + let parsedUrlForHints: URL | null = null; const pushStep = ( name: string, @@ -571,242 +455,11 @@ export async function runOpenclawGatewayTest( success, steps, totalDurationMs: Date.now() - overallStart, - ...(Object.keys(serverInfo).length > 0 ? { serverInfo } : {}), diagnostics: diagnosticsResult, ...(error ? { error } : {}), }; }; - let parsedUrlForHints: URL | null = null; - - const waitForGatewayEvent = ( - socket: NodeWebSocket, - eventName: string, - ): Promise | undefined> => - new Promise((resolve, reject) => { - const bufferedIndex = earlyGatewayEvents.findIndex( - (message) => message.type === "event" && message.event === eventName, - ); - if (bufferedIndex >= 0) { - const [message] = earlyGatewayEvents.splice(bufferedIndex, 1); - resolve( - typeof message?.payload === "object" && message.payload !== null - ? (message.payload as Record) - : undefined, - ); - return; - } - - let settled = false; - let timeout: ReturnType | undefined; - - const cleanup = () => { - if (timeout) { - clearTimeout(timeout); - } - socket.off("message", onMessage); - socket.off("close", onClose); - socket.off("error", onError); - }; - - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - cleanup(); - callback(); - }; - - const onMessage = (data: NodeWebSocket.Data) => { - const message = parseGatewayEnvelope(data); - if (!message) { - return; - } - if (message.type === "event" && typeof message.event === "string") { - pushUnique(diagnostics.observedNotifications, message.event); - if (message.event === eventName) { - settle(() => - resolve( - typeof message.payload === "object" && message.payload !== null - ? (message.payload as Record) - : undefined, - ), - ); - } - } - }; - - const onClose = (code: number, reasonBuffer: Buffer) => { - diagnostics.socketCloseCode = code; - const reason = reasonBuffer.toString("utf8"); - if (reason.length > 0) { - diagnostics.socketCloseReason = reason; - } - const closeDetail = formatSocketClose(code, reason); - settle(() => - reject( - new Error( - `WebSocket closed before gateway event '${eventName}' arrived${ - closeDetail ? ` (${closeDetail})` : "" - }.`, - ), - ), - ); - }; - - const onError = (cause: Error) => { - diagnostics.socketError = toMessage(cause, "WebSocket error."); - settle(() => - reject( - new Error( - `WebSocket error while waiting for gateway event '${eventName}': ${diagnostics.socketError}`, - ), - ), - ); - }; - - socket.on("message", onMessage); - socket.on("close", onClose); - socket.on("error", onError); - - timeout = setTimeout(() => { - settle(() => - reject(new Error(buildTimeoutDetail(`Gateway event '${eventName}'`, diagnostics))), - ); - }, OPENCLAW_TEST_RPC_TIMEOUT_MS); - }); - - const sendGatewayRequest = ( - socket: NodeWebSocket, - method: string, - params?: Record, - ): Promise<{ payload?: unknown; error?: ParsedGatewayError }> => - new Promise((resolve, reject) => { - const id = String(rpcId++); - let settled = false; - let timeout: ReturnType | undefined; - - const cleanup = () => { - if (timeout) { - clearTimeout(timeout); - } - socket.off("message", onMessage); - socket.off("close", onClose); - socket.off("error", onError); - }; - - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - cleanup(); - callback(); - }; - - const onMessage = (data: NodeWebSocket.Data) => { - const message = parseGatewayEnvelope(data); - if (!message) { - return; - } - if (message.type === "event" && typeof message.event === "string") { - pushUnique(diagnostics.observedNotifications, message.event); - return; - } - if (message.type === "res" && message.id === id) { - if (message.ok === true) { - recordGatewayError(diagnostics, undefined); - settle(() => - resolve( - message.payload !== undefined - ? { payload: message.payload } - : { payload: undefined }, - ), - ); - return; - } - - const parsedError = parseGatewayError(message.error); - recordGatewayError(diagnostics, parsedError); - settle(() => resolve({ error: parsedError })); - } - }; - - const onClose = (code: number, reasonBuffer: Buffer) => { - diagnostics.socketCloseCode = code; - const reason = reasonBuffer.toString("utf8"); - if (reason.length > 0) { - diagnostics.socketCloseReason = reason; - } - const closeDetail = formatSocketClose(code, reason); - settle(() => - reject( - new Error( - `WebSocket closed before gateway request '${method}' completed${ - closeDetail ? ` (${closeDetail})` : "" - }.`, - ), - ), - ); - }; - - const onError = (cause: Error) => { - diagnostics.socketError = toMessage(cause, "WebSocket error."); - settle(() => - reject( - new Error( - `WebSocket error during gateway request '${method}': ${diagnostics.socketError}`, - ), - ), - ); - }; - - socket.on("message", onMessage); - socket.on("close", onClose); - socket.on("error", onError); - - timeout = setTimeout(() => { - settle(() => - reject(new Error(buildTimeoutDetail(`Gateway request '${method}'`, diagnostics))), - ); - }, OPENCLAW_TEST_RPC_TIMEOUT_MS); - - try { - socket.send( - JSON.stringify({ - type: "req", - id, - method, - ...(params !== undefined ? { params } : {}), - }), - ); - } catch (cause) { - diagnostics.socketError = toMessage(cause, "WebSocket send failed."); - settle(() => reject(cause instanceof Error ? cause : new Error(diagnostics.socketError))); - } - }); - - const buildConnectParams = (sharedSecret: string | undefined): Record => ({ - minProtocol: OPENCLAW_PROTOCOL_VERSION, - maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: { - id: "okcode", - version: serverBuildInfo.version, - platform: - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : process.platform, - mode: "operator", - }, - role: "operator", - scopes: [...OPENCLAW_OPERATOR_SCOPES], - caps: [], - commands: [], - permissions: {}, - locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", - userAgent: `okcode/${serverBuildInfo.version}`, - ...(sharedSecret ? { auth: { password: sharedSecret } } : {}), - }); - try { const urlStart = Date.now(); const gatewayUrl = input.gatewayUrl.trim(); @@ -851,44 +504,33 @@ export async function runOpenclawGatewayTest( diagnostics.hostKind = classifyGatewayHost(parsedUrl.hostname, diagnostics.resolvedAddresses); const connectStart = Date.now(); + let connection: Awaited> | undefined; try { - ws = await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(gatewayUrl); - socket.on("message", (data: NodeWebSocket.Data) => { - const message = parseGatewayEnvelope(data); - if (!message) { - return; - } - if (message.type === "event" && typeof message.event === "string") { - pushUnique(diagnostics.observedNotifications, message.event); - } - if (captureEarlyGatewayEvents) { - earlyGatewayEvents.push(message); - } - }); - const timeout = setTimeout(() => { - socket.close(); - reject(new Error(`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`)); - }, OPENCLAW_TEST_CONNECT_TIMEOUT_MS); - - socket.on("open", () => { - clearTimeout(timeout); - resolve(socket); - }); - socket.on("error", (cause) => { - clearTimeout(timeout); - reject(cause); - }); - }); - ws.on("close", (code: number, reasonBuffer: Buffer) => { - diagnostics.socketCloseCode = code; - const reason = reasonBuffer.toString("utf8"); - if (reason.length > 0) { - diagnostics.socketCloseReason = reason; - } - }); - ws.on("error", (cause: Error) => { - diagnostics.socketError = toMessage(cause, "WebSocket error."); + connection = await connectOpenClawGateway({ + gatewayUrl, + stateDir: options?.stateDir, + sessionKey: "okcode:gateway-test", + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + client: { + id: "okcode", + version: serverBuildInfo.version, + platform: + process.platform === "darwin" + ? "macos" + : process.platform === "win32" + ? "windows" + : process.platform, + mode: "operator", + }, + userAgent: `okcode/${serverBuildInfo.version}`, + locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", + password: sharedSecret, + onEvent: (event) => { + pushUnique(diagnostics.observedNotifications, event.event); + }, + connectTimeoutMs: OPENCLAW_TEST_CONNECT_TIMEOUT_MS, + requestTimeoutMs: OPENCLAW_TEST_RPC_TIMEOUT_MS, }); pushStep( "WebSocket connect", @@ -897,6 +539,11 @@ export async function runOpenclawGatewayTest( `Connected in ${Date.now() - connectStart}ms`, ); } catch (cause) { + const gatewayError = + cause instanceof Error + ? (cause as Error & { readonly gatewayError?: OpenClawGatewayErrorLike }).gatewayError + : undefined; + applyGatewayError(diagnostics, gatewayError); const detail = toMessage(cause, "Connection failed."); pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail); applyHealthProbe(await healthPromise); @@ -906,31 +553,13 @@ export async function runOpenclawGatewayTest( applyHealthProbe(await healthPromise); const handshakeStart = Date.now(); - try { - await waitForGatewayEvent(ws, "connect.challenge"); - captureEarlyGatewayEvents = false; - earlyGatewayEvents.length = 0; - const connectResult = await sendGatewayRequest( - ws, - "connect", - buildConnectParams(sharedSecret), - ); - if (connectResult.error) { - const detail = formatGatewayError(connectResult.error); - pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); - return finalize(false, detail, "Gateway handshake"); - } - pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); - } catch (cause) { - const detail = toMessage(cause, "Gateway handshake failed."); - pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); - return finalize(false, detail, "Gateway handshake"); - } - + pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); return finalize(true); } finally { - if (ws && ws.readyState === NodeWebSocket.OPEN) { - ws.close(); + try { + await connection?.close(); + } catch { + // ignore close errors during cleanup } } } diff --git a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts new file mode 100644 index 000000000..e600cb2f2 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts @@ -0,0 +1,781 @@ +import { + createHash, + generateKeyPairSync, + sign as cryptoSign, + createPrivateKey, + createPublicKey, +} from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import { join } from "node:path"; + +import WebSocket from "ws"; + +const OPENCLAW_PROTOCOL_VERSION = 3; +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const AUTH_STATE_FILE_NAME = "openclaw-gateway-auth.json"; + +export interface OpenClawGatewayClientInfo { + readonly id: string; + readonly version: string; + readonly platform: string; + readonly mode: "operator" | "node"; +} + +export interface OpenClawGatewayConnectOptions { + readonly gatewayUrl: string; + readonly stateDir?: string; + readonly sessionKey?: string; + readonly role: "operator" | "node"; + readonly scopes: ReadonlyArray; + readonly client: OpenClawGatewayClientInfo; + readonly userAgent: string; + readonly locale?: string; + readonly caps?: ReadonlyArray; + readonly commands?: ReadonlyArray; + readonly permissions?: Record; + readonly password?: string; + readonly deviceToken?: string; + readonly onEvent?: (event: OpenClawGatewayEvent) => void; + readonly connectTimeoutMs?: number; + readonly requestTimeoutMs?: number; +} + +export interface OpenClawGatewayEvent { + readonly event: string; + readonly payload?: unknown; + readonly seq?: number; + readonly stateVersion?: number; +} + +export interface OpenClawGatewayError { + readonly code?: string; + readonly message: string; + readonly details?: Record; +} + +export interface OpenClawGatewayRequestResult { + readonly ok: boolean; + readonly payload?: T; + readonly error?: OpenClawGatewayError; +} + +export interface OpenClawGatewayConnection { + readonly origin: string; + readonly sessionKey: string; + readonly deviceId: string; + request( + method: string, + params?: Record, + timeoutMs?: number, + ): Promise>; + close(): Promise; +} + +interface PersistedOpenClawGatewayAuthState { + readonly version: 1; + readonly device: { + readonly id: string; + readonly privateKeyPem: string; + readonly publicKeyPem: string; + }; + readonly deviceTokens: Record; +} + +interface OpenClawDeviceIdentity { + readonly id: string; + readonly privateKeyPem: string; + readonly publicKeyPem: string; +} + +interface GatewayFrame { + readonly type?: unknown; + readonly id?: unknown; + readonly ok?: unknown; + readonly method?: unknown; + readonly event?: unknown; + readonly params?: unknown; + readonly payload?: unknown; + readonly error?: unknown; +} + +interface GatewayChallengePayload { + readonly nonce?: unknown; + readonly ts?: unknown; +} + +interface GatewayConnectPayload { + readonly type?: unknown; + readonly protocol?: unknown; + readonly auth?: { + readonly deviceToken?: unknown; + }; +} + +type OpenClawGatewayAuthSelection = + | { readonly kind: "password"; readonly value: string } + | { readonly kind: "deviceToken"; readonly value: string } + | { readonly kind: "none" }; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function normalizePathSegments(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]+/g, "-"); +} + +function getDefaultStateDir(): string { + return join(os.tmpdir(), "okcode-openclaw-gateway"); +} + +function getAuthStatePath(stateDir: string): string { + return join(stateDir, "openclaw", AUTH_STATE_FILE_NAME); +} + +function exportPublicKeyPem(publicKey: ReturnType): string { + return publicKey.export({ format: "pem", type: "spki" }).toString(); +} + +function exportPrivateKeyPem(privateKey: ReturnType): string { + return privateKey.export({ format: "pem", type: "pkcs8" }).toString(); +} + +function fingerprintPublicKey(publicKeyPem: string): string { + const publicKey = createPublicKey(publicKeyPem); + const publicKeyDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + return createHash("sha256").update(publicKeyDer).digest("hex"); +} + +function makeDeviceIdentity(): OpenClawDeviceIdentity { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const privateKeyPem = exportPrivateKeyPem(privateKey); + const publicKeyPem = exportPublicKeyPem(publicKey); + const deviceId = `device_${fingerprintPublicKey(publicKeyPem)}`; + return { + id: deviceId, + privateKeyPem, + publicKeyPem, + }; +} + +function buildSignaturePayload(input: { + readonly nonce: string; + readonly signedAt: number; + readonly client: OpenClawGatewayClientInfo; + readonly role: "operator" | "node"; + readonly scopes: ReadonlyArray; + readonly authValue: string | undefined; + readonly deviceFamily: string; +}): string { + return JSON.stringify({ + version: 3, + nonce: input.nonce, + signedAt: input.signedAt, + client: input.client, + role: input.role, + scopes: input.scopes, + authValue: input.authValue ?? null, + deviceFamily: input.deviceFamily, + }); +} + +function signChallenge( + identity: OpenClawDeviceIdentity, + input: Parameters[0], +): string { + const privateKey = createPrivateKey(identity.privateKeyPem); + const signature = cryptoSign(null, Buffer.from(buildSignaturePayload(input)), privateKey); + return signature.toString("base64"); +} + +async function readAuthState(stateDir: string): Promise { + try { + const raw = await readFile(getAuthStatePath(stateDir), "utf8"); + const parsed = JSON.parse(raw) as unknown; + if ( + !isObject(parsed) || + parsed.version !== 1 || + !isObject(parsed.device) || + typeof parsed.device.id !== "string" || + typeof parsed.device.privateKeyPem !== "string" || + typeof parsed.device.publicKeyPem !== "string" || + !isObject(parsed.deviceTokens) + ) { + return null; + } + return { + version: 1, + device: { + id: parsed.device.id, + privateKeyPem: parsed.device.privateKeyPem, + publicKeyPem: parsed.device.publicKeyPem, + }, + deviceTokens: Object.fromEntries( + Object.entries(parsed.deviceTokens).filter( + ([origin, token]) => typeof origin === "string" && typeof token === "string", + ), + ), + }; + } catch { + return null; + } +} + +async function writeAuthState( + stateDir: string, + state: PersistedOpenClawGatewayAuthState, +): Promise { + await mkdir(join(stateDir, "openclaw"), { recursive: true }); + await writeFile(getAuthStatePath(stateDir), `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +class OpenClawGatewayAuthStore { + private cachedState: PersistedOpenClawGatewayAuthState | undefined; + + constructor(private readonly stateDir: string) {} + + private async loadState(): Promise { + if (this.cachedState !== undefined) { + return this.cachedState; + } + + const loaded = (await readAuthState(this.stateDir)) ?? { + version: 1, + device: makeDeviceIdentity(), + deviceTokens: {}, + }; + this.cachedState = loaded; + if ((await readAuthState(this.stateDir)) === null) { + await writeAuthState(this.stateDir, loaded); + } + return loaded; + } + + async getDeviceIdentity(): Promise { + const state = await this.loadState(); + return state.device; + } + + async getDeviceToken(origin: string): Promise { + const state = await this.loadState(); + return state.deviceTokens[origin]; + } + + async persistDeviceToken(origin: string, token: string): Promise { + const state = await this.loadState(); + if (state.deviceTokens[origin] === token) { + return; + } + this.cachedState = { + ...state, + deviceTokens: { + ...state.deviceTokens, + [origin]: token, + }, + }; + await writeAuthState(this.stateDir, this.cachedState); + } +} + +function parseFrame(data: WebSocket.Data): GatewayFrame | null { + try { + const raw = + typeof data === "string" + ? data + : data instanceof ArrayBuffer + ? Buffer.from(data).toString("utf8") + : Array.isArray(data) + ? Buffer.concat(data).toString("utf8") + : data.toString("utf8"); + const parsed = JSON.parse(raw) as unknown; + return isObject(parsed) ? (parsed as GatewayFrame) : null; + } catch { + return null; + } +} + +function makeRequestError(message: string): Error { + return new Error(message); +} + +function toGatewayError(frameError: unknown): OpenClawGatewayError { + if (!isObject(frameError)) { + return { message: "Gateway request failed." }; + } + const details = isObject(frameError.details) ? frameError.details : undefined; + return { + message: readString(frameError.message) ?? "Gateway request failed.", + ...(readString(frameError.code) ? { code: readString(frameError.code) } : {}), + ...(details ? { details } : {}), + }; +} + +function buildConnectParams(input: { + readonly client: OpenClawGatewayClientInfo; + readonly role: "operator" | "node"; + readonly scopes: ReadonlyArray; + readonly auth: OpenClawGatewayAuthSelection; + readonly challengeNonce: string; + readonly deviceIdentity: OpenClawDeviceIdentity; + readonly userAgent: string; + readonly locale?: string; + readonly caps?: ReadonlyArray; + readonly commands?: ReadonlyArray; + readonly permissions?: Record; + readonly deviceFamily: string; +}): Record { + const signedAt = Date.now(); + return { + minProtocol: OPENCLAW_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_PROTOCOL_VERSION, + client: input.client, + role: input.role, + scopes: [...input.scopes], + caps: [...(input.caps ?? [])], + commands: [...(input.commands ?? [])], + permissions: { ...(input.permissions ?? {}) }, + ...(input.auth.kind === "password" + ? { + auth: { + password: input.auth.value, + }, + } + : input.auth.kind === "deviceToken" + ? { + auth: { + deviceToken: input.auth.value, + }, + } + : {}), + locale: input.locale ?? (Intl.DateTimeFormat().resolvedOptions().locale || "en-US"), + userAgent: input.userAgent, + device: { + id: input.deviceIdentity.id, + publicKey: input.deviceIdentity.publicKeyPem, + signature: signChallenge(input.deviceIdentity, { + nonce: input.challengeNonce, + signedAt, + client: input.client, + role: input.role, + scopes: input.scopes, + authValue: input.authValue, + deviceFamily: input.deviceFamily, + }), + signedAt, + nonce: input.challengeNonce, + }, + }; +} + +function isDeviceTokenError(error: OpenClawGatewayError | undefined): boolean { + const code = + error?.details && isObject(error.details) ? readString(error.details.code) : undefined; + return ( + code === "AUTH_TOKEN_MISMATCH" || + code === "AUTH_DEVICE_TOKEN_MISMATCH" || + code?.startsWith("DEVICE_AUTH_") === true || + error?.message.toLowerCase().includes("auth_token_mismatch") === true + ); +} + +export function createOpenClawIdempotencyKey(parts: ReadonlyArray): string { + return `okcode-${createHash("sha256").update(parts.join("\u0000")).digest("hex")}`; +} + +export async function connectOpenClawGateway( + options: OpenClawGatewayConnectOptions, +): Promise { + const parsedUrl = new URL(options.gatewayUrl); + const origin = parsedUrl.origin; + const stateDir = options.stateDir ?? getDefaultStateDir(); + const authStore = new OpenClawGatewayAuthStore(stateDir); + const deviceIdentity = await authStore.getDeviceIdentity(); + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + const deviceFamily = "server"; + + const candidateAuthSelections: OpenClawGatewayAuthSelection[] = []; + if (options.password && options.password.length > 0) { + candidateAuthSelections.push({ kind: "password", value: options.password }); + } + if (options.deviceToken && options.deviceToken.length > 0) { + candidateAuthSelections.push({ kind: "deviceToken", value: options.deviceToken }); + } + const cachedDeviceToken = await authStore.getDeviceToken(origin); + if (cachedDeviceToken) { + candidateAuthSelections.push({ kind: "deviceToken", value: cachedDeviceToken }); + } + if (candidateAuthSelections.length === 0) { + candidateAuthSelections.push({ kind: "none" }); + } + + let lastError: Error | undefined; + + for (let index = 0; index < candidateAuthSelections.length; index += 1) { + const auth = candidateAuthSelections[index]; + try { + const connection = await connectOnce({ + gatewayUrl: options.gatewayUrl, + origin, + authStore, + deviceIdentity, + auth, + connectTimeoutMs, + requestTimeoutMs, + onEvent: options.onEvent, + client: options.client, + role: options.role, + scopes: options.scopes, + userAgent: options.userAgent, + locale: options.locale, + caps: options.caps, + commands: options.commands, + permissions: options.permissions, + deviceFamily, + sessionKey: options.sessionKey ?? `okcode:${normalizePathSegments(options.client.id)}`, + }); + return connection; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + lastError = error; + const parsedError = error as Error & { readonly gatewayError?: OpenClawGatewayError }; + const gatewayError = parsedError.gatewayError; + const usedExplicitPassword = options.password !== undefined && options.password.length > 0; + const canRetryWithCachedToken = + usedExplicitPassword && + cachedDeviceToken !== undefined && + auth.kind === "password" && + isDeviceTokenError(gatewayError); + + if (!canRetryWithCachedToken || index + 1 >= candidateAuthSelections.length) { + break; + } + } + } + + throw lastError ?? new Error("OpenClaw gateway connect failed."); +} + +async function connectOnce(input: { + readonly gatewayUrl: string; + readonly origin: string; + readonly authStore: OpenClawGatewayAuthStore; + readonly deviceIdentity: OpenClawDeviceIdentity; + readonly auth: OpenClawGatewayAuthSelection; + readonly connectTimeoutMs: number; + readonly requestTimeoutMs: number; + readonly onEvent?: (event: OpenClawGatewayEvent) => void; + readonly client: OpenClawGatewayClientInfo; + readonly role: "operator" | "node"; + readonly scopes: ReadonlyArray; + readonly userAgent: string; + readonly locale?: string; + readonly caps?: ReadonlyArray; + readonly commands?: ReadonlyArray; + readonly permissions?: Record; + readonly deviceFamily: string; + readonly sessionKey: string; +}): Promise { + return await new Promise((resolve, reject) => { + const ws = new WebSocket(input.gatewayUrl); + const pendingRequests = new Map< + string, + { + readonly resolve: (value: OpenClawGatewayRequestResult) => void; + readonly reject: (reason: unknown) => void; + } + >(); + const bufferedEvents: OpenClawGatewayEvent[] = []; + let connected = false; + let closed = false; + let handshakeSettled = false; + let nextRequestId = 1; + let challengeNonce: string | undefined; + let challengeResolved = false; + let resolveChallenge: + | ((value: { readonly nonce: string; readonly ts?: number }) => void) + | undefined; + let rejectChallenge: ((reason: Error) => void) | undefined; + const challengePromise = new Promise<{ readonly nonce: string; readonly ts?: number }>( + (resolveChallengePromise, rejectChallengePromise) => { + resolveChallenge = resolveChallengePromise; + rejectChallenge = rejectChallengePromise; + }, + ); + + const cleanup = (): void => { + ws.off("message", onMessage); + ws.off("close", onClose); + ws.off("error", onError); + }; + + const rejectAllPending = (reason: unknown): void => { + for (const [, pending] of pendingRequests) { + pending.reject(reason); + } + pendingRequests.clear(); + }; + + const settleHandshakeFailure = (reason: Error): void => { + if (handshakeSettled) { + return; + } + handshakeSettled = true; + closed = true; + rejectChallenge?.(reason); + cleanup(); + try { + ws.close(); + } catch { + // ignore close errors + } + reject(reason); + }; + + const deliverBufferedEvents = (): void => { + if (bufferedEvents.length === 0) { + return; + } + for (const event of bufferedEvents.splice(0)) { + input.onEvent?.(event); + } + }; + + const onClose = (code: number, reasonBuffer: Buffer): void => { + closed = true; + const reasonText = reasonBuffer.toString("utf8"); + const closeError = new Error( + reasonText.length > 0 + ? `WebSocket closed with code ${code}: ${reasonText}` + : `WebSocket closed with code ${code}`, + ); + rejectAllPending(closeError); + if (!challengeResolved) { + rejectChallenge?.(closeError); + } + if (!handshakeSettled) { + settleHandshakeFailure(closeError); + } + }; + + const onError = (cause: Error): void => { + if (!handshakeSettled) { + settleHandshakeFailure(cause); + return; + } + rejectAllPending(cause); + if (!challengeResolved) { + rejectChallenge?.(cause); + } + }; + + const onMessage = (data: WebSocket.Data): void => { + const frame = parseFrame(data); + if (!frame || closed) { + return; + } + + if (frame.type === "event") { + const eventName = readString(frame.event); + if (!eventName) { + return; + } + if (eventName === "connect.challenge") { + const payload = isObject(frame.payload) + ? (frame.payload as GatewayChallengePayload) + : undefined; + const nonce = readString(payload?.nonce); + if (nonce && !challengeNonce) { + challengeNonce = nonce; + challengeResolved = true; + resolveChallenge?.({ + nonce, + ...(typeof payload?.ts === "number" ? { ts: payload.ts } : {}), + }); + } + return; + } + const event: OpenClawGatewayEvent = { + event: eventName, + ...(frame.payload !== undefined ? { payload: frame.payload } : {}), + ...(typeof frame.seq === "number" ? { seq: frame.seq } : {}), + ...(typeof frame.stateVersion === "number" ? { stateVersion: frame.stateVersion } : {}), + }; + if (!connected) { + bufferedEvents.push(event); + return; + } + input.onEvent?.(event); + return; + } + + if (frame.type !== "res") { + return; + } + + const id = readString(frame.id); + if (id === undefined) { + return; + } + const pending = pendingRequests.get(id); + if (pending === undefined) { + return; + } + pendingRequests.delete(id); + if (frame.ok === true) { + const payload = frame.payload as GatewayConnectPayload | undefined; + if (payload && isObject(payload) && payload.type === "hello-ok") { + const auth = isObject(payload.auth) ? payload.auth : undefined; + const token = readString(auth?.deviceToken); + if (token) { + void input.authStore.persistDeviceToken(input.origin, token); + } + } + pending.resolve({ + ok: true, + ...(frame.payload !== undefined ? { payload: frame.payload } : {}), + }); + return; + } + const gatewayError = toGatewayError(frame.error); + pending.resolve({ ok: false, error: gatewayError }); + }; + + ws.on("message", onMessage); + ws.on("close", onClose); + ws.on("error", onError); + + const connectTimeout = setTimeout(() => { + settleHandshakeFailure( + makeRequestError( + `Connection to ${input.gatewayUrl} timed out after ${input.connectTimeoutMs}ms.`, + ), + ); + }, input.connectTimeoutMs); + + ws.once("open", () => { + void (async () => { + try { + const challenge = await challengePromise; + challengeNonce = challenge.nonce; + const requestId = `connect-${nextRequestId++}`; + const requestResult = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + }); + ws.send( + JSON.stringify({ + type: "req", + id: requestId, + method: "connect", + params: buildConnectParams({ + client: input.client, + role: input.role, + scopes: input.scopes, + auth: input.auth, + challengeNonce, + deviceIdentity: input.deviceIdentity, + userAgent: input.userAgent, + locale: input.locale, + caps: input.caps, + commands: input.commands, + permissions: input.permissions, + deviceFamily: input.deviceFamily, + }), + }), + ); + const response = await requestResult; + clearTimeout(connectTimeout); + handshakeSettled = true; + if (!response.ok) { + const error = new Error(response.error?.message ?? "Gateway connect failed."); + (error as Error & { readonly gatewayError?: OpenClawGatewayError }).gatewayError = + response.error; + cleanup(); + try { + ws.close(); + } catch { + // ignore close errors + } + reject(error); + return; + } + connected = true; + deliverBufferedEvents(); + resolve({ + origin: input.origin, + sessionKey: input.sessionKey, + deviceId: input.deviceIdentity.id, + request( + method: string, + params?: Record, + timeoutMs?: number, + ) { + if (closed) { + return Promise.reject(makeRequestError("Gateway connection is closed.")); + } + const id = `req-${nextRequestId++}`; + const deadlineMs = timeoutMs ?? input.requestTimeoutMs; + return new Promise>( + (resolveRequest, rejectRequest) => { + const timer = setTimeout(() => { + pendingRequests.delete(id); + rejectRequest( + makeRequestError(`RPC call '${method}' timed out after ${deadlineMs}ms.`), + ); + }, deadlineMs); + + pendingRequests.set(id, { + resolve: (result) => { + clearTimeout(timer); + resolveRequest(result as OpenClawGatewayRequestResult); + }, + reject: (reason) => { + clearTimeout(timer); + rejectRequest(reason); + }, + }); + + try { + ws.send( + JSON.stringify({ + type: "req", + id, + method, + ...(params !== undefined ? { params } : {}), + }), + ); + } catch (cause) { + clearTimeout(timer); + pendingRequests.delete(id); + rejectRequest(cause); + } + }, + ); + }, + close: async () => { + closed = true; + clearTimeout(connectTimeout); + rejectAllPending(makeRequestError("Gateway connection closed.")); + cleanup(); + try { + ws.close(); + } catch { + // ignore close errors + } + }, + }); + } catch (cause) { + clearTimeout(connectTimeout); + const error = cause instanceof Error ? cause : new Error(String(cause)); + settleHandshakeFailure(error); + } + })(); + }); + }); +}