From 03025fd9964f5a7714e3be6ef34ac395b0dfa3be Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 02:07:35 -0500 Subject: [PATCH] Switch Openclaw gateway test to modern connect handshake - Replace legacy auth/session RPC flow with connect challenge handling - Surface gateway error details and pairing hints in diagnostics - Update contract and settings UI to show the new handshake fields --- apps/server/src/openclawGatewayTest.test.ts | 91 +++- apps/server/src/openclawGatewayTest.ts | 495 +++++++++++++++----- apps/web/src/routes/_chat.settings.tsx | 65 ++- packages/contracts/src/server.ts | 5 + 4 files changed, 532 insertions(+), 124 deletions(-) diff --git a/apps/server/src/openclawGatewayTest.test.ts b/apps/server/src/openclawGatewayTest.test.ts index 7c6546db..9b28327d 100644 --- a/apps/server/src/openclawGatewayTest.test.ts +++ b/apps/server/src/openclawGatewayTest.test.ts @@ -5,6 +5,12 @@ import { OpenclawGatewayTestInternals, runOpenclawGatewayTest } from "./openclaw const servers = new Set(); +type GatewayRequestFrame = { + type?: unknown; + id?: unknown; + method?: unknown; +}; + afterEach(async () => { await Promise.all( [...servers].map( @@ -36,8 +42,18 @@ async function createGatewayServer( return { url: `ws://127.0.0.1:${address.port}` }; } +function sendChallenge(socket: WebSocket): void { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123", ts: Date.now() }, + }), + ); +} + describe("runOpenclawGatewayTest", () => { - it("captures Tailscale-oriented hints for auth timeouts", () => { + it("captures Tailscale-oriented hints for modern handshake timeouts", () => { const hostKind = OpenclawGatewayTestInternals.classifyGatewayHost("vals-mini.example.ts.net", [ "100.90.12.34", ]); @@ -50,27 +66,71 @@ describe("runOpenclawGatewayTest", () => { resolvedAddresses: ["100.90.12.34"], hostKind, healthStatus: "skip", - observedNotifications: [], + observedNotifications: ["connect.challenge"], hints: [], }, - "Authentication", - "RPC 'auth.authenticate' timed out after 10000ms.", + "Gateway handshake", + "Gateway request 'connect' timed out after 10000ms.", true, ); expect(hints.some((hint) => hint.includes("Tailscale"))).toBe(true); - expect(hints.some((hint) => hint.includes("actual OpenClaw JSON-RPC gateway endpoint"))).toBe( + expect(hints.some((hint) => hint.includes("actual OpenClaw WebSocket gateway endpoint"))).toBe( true, ); expect(hints.some((hint) => hint.includes("reverse proxy"))).toBe(true); }); - it("reports socket-close details when auth fails mid-handshake", async () => { + it("passes when the modern connect handshake succeeds", async () => { + const gateway = await createGatewayServer((socket) => { + sendChallenge(socket); + socket.on("message", (data) => { + const message = JSON.parse(data.toString()) as GatewayRequestFrame; + if (message.type === "req" && message.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: true, + payload: { type: "hello-ok", protocol: 3 }, + }), + ); + } + }); + }); + + const result = await runOpenclawGatewayTest({ + gatewayUrl: gateway.url, + password: "topsecret", + }); + + expect(result.success).toBe(true); + expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); + expect(result.steps.find((step) => step.name === "Gateway handshake")?.status).toBe("pass"); + expect(result.diagnostics?.observedNotifications).toContain("connect.challenge"); + }); + + it("reports pairing-required detail codes from the connect handshake", async () => { const gateway = await createGatewayServer((socket) => { + sendChallenge(socket); socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as { method?: string }; - if (message.method === "auth.authenticate") { - socket.close(4401, "gateway auth unavailable"); + const message = JSON.parse(data.toString()) as GatewayRequestFrame; + if (message.type === "req" && message.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: false, + error: { + message: "device is not approved", + details: { + code: "PAIRING_REQUIRED", + reason: "pairing-required", + recommendedNextStep: "approve_device", + }, + }, + }), + ); } }); }); @@ -83,12 +143,13 @@ describe("runOpenclawGatewayTest", () => { expect(result.success).toBe(false); expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); - const authStep = result.steps.find((step) => step.name === "Authentication"); - expect(authStep?.status).toBe("fail"); - expect(authStep?.detail).toContain("WebSocket closed before RPC 'auth.authenticate' completed"); + const handshakeStep = result.steps.find((step) => step.name === "Gateway handshake"); + expect(handshakeStep?.status).toBe("fail"); + expect(handshakeStep?.detail).toContain("PAIRING_REQUIRED"); - expect(result.diagnostics?.socketCloseCode).toBe(4401); - expect(result.diagnostics?.socketCloseReason).toBe("gateway auth unavailable"); - expect(result.diagnostics?.hints.some((hint) => hint.includes("loopback-only"))).toBe(true); + expect(result.diagnostics?.gatewayErrorDetailCode).toBe("PAIRING_REQUIRED"); + expect(result.diagnostics?.gatewayErrorDetailReason).toBe("pairing-required"); + expect(result.diagnostics?.gatewayRecommendedNextStep).toBe("approve_device"); + expect(result.diagnostics?.hints.some((hint) => hint.includes("pairing approval"))).toBe(true); }); }); diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index 2fbf1366..c33707d6 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -10,18 +10,27 @@ import type { TestOpenclawGatewayStepStatus, } from "@okcode/contracts"; import NodeWebSocket from "ws"; +import { serverBuildInfo } from "./buildInfo.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; - -type JsonRpcEnvelope = { - id?: number | string | null; - method?: string; - result?: unknown; - error?: { code: number; message: string }; +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 { @@ -42,10 +51,24 @@ interface MutableGatewayDiagnostics { socketCloseCode?: number; socketCloseReason?: string; socketError?: string; + gatewayErrorCode?: string; + gatewayErrorDetailCode?: string; + gatewayErrorDetailReason?: string; + gatewayRecommendedNextStep?: string; + gatewayCanRetryWithDeviceToken?: boolean; observedNotifications: string[]; hints: string[]; } +interface ParsedGatewayError { + message: string; + code?: string; + detailCode?: string; + detailReason?: string; + recommendedNextStep?: string; + canRetryWithDeviceToken?: boolean; +} + function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => resolve(fallback), timeoutMs); @@ -76,6 +99,105 @@ function bufferToString(data: NodeWebSocket.Data): string { 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( + diagnostics: MutableGatewayDiagnostics, + error: ParsedGatewayError | 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; +} + function pushUnique(items: string[], value: string): void { if (items.includes(value) || items.length >= MAX_CAPTURED_NOTIFICATIONS) return; items.push(value); @@ -226,8 +348,8 @@ function formatSocketClose(code: number | undefined, reason: string | undefined) return reason && reason.length > 0 ? `code ${code}: ${reason}` : `code ${code}`; } -function buildTimeoutDetail(method: string, diagnostics: TestOpenclawGatewayDiagnostics): string { - const parts = [`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms.`]; +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}.`); @@ -236,7 +358,7 @@ function buildTimeoutDetail(method: string, diagnostics: TestOpenclawGatewayDiag parts.push(`Last socket error: ${diagnostics.socketError}.`); } if (diagnostics.observedNotifications.length > 0) { - parts.push(`Observed notifications: ${diagnostics.observedNotifications.join(", ")}.`); + parts.push(`Observed gateway events: ${diagnostics.observedNotifications.join(", ")}.`); } return parts.join(" "); } @@ -251,16 +373,22 @@ function buildHints( | "observedNotifications" | "hints" | "resolvedAddresses" + | "gatewayErrorCode" + | "gatewayErrorDetailCode" + | "gatewayErrorDetailReason" + | "gatewayRecommendedNextStep" + | "gatewayCanRetryWithDeviceToken" >, failedStepName: string | null, error: string | undefined, - passwordProvided: boolean, + sharedSecretProvided: boolean, ): string[] { const hints: string[] = []; - const authFailure = failedStepName === "Authentication"; + const handshakeFailure = failedStepName === "Gateway handshake"; const websocketFailure = failedStepName === "WebSocket connect"; - const sessionFailure = failedStepName === "Session create"; const errorLower = error?.toLowerCase() ?? ""; + const detailCode = diagnostics.gatewayErrorDetailCode; + const gatewayRecommendedNextStep = diagnostics.gatewayRecommendedNextStep; if (diagnostics.hostKind === "loopback") { hints.push( @@ -286,29 +414,39 @@ function buildHints( ); } - if (authFailure) { + if (handshakeFailure) { hints.push( - "The WebSocket handshake succeeded, so DNS/TLS/basic routing are working. The missing piece is the gateway’s JSON-RPC auth response.", + "The WebSocket handshake succeeded, so DNS/TLS/basic routing are working. The remaining failure is inside the OpenClaw `connect` handshake.", ); - if (errorLower.includes("timed out")) { + if (errorLower.includes("connect.challenge")) { hints.push( - "A timeout during `auth.authenticate` usually means this URL is not the actual OpenClaw JSON-RPC gateway endpoint, the gateway auth handler is stalled, or a proxy is accepting WebSockets without forwarding gateway traffic correctly.", + "Modern OpenClaw gateways send `connect.challenge` before they will accept any client request. If that event never arrived, this URL may point at the wrong WebSocket service or an intermediary is swallowing frames.", ); + } + if (errorLower.includes("timed out")) { hints.push( - "A wrong password normally returns an RPC error quickly. A timeout is more consistent with the gateway never replying than with a simple credential mismatch.", + "A timeout during the `connect.challenge`/`connect` exchange usually means this URL is not the actual OpenClaw WebSocket gateway endpoint, or a proxy/Tailscale Serve setup upgraded the socket but did not keep forwarding frames.", ); } } - if (!passwordProvided && sessionFailure) { + if ( + !sharedSecretProvided && + (detailCode === "AUTH_TOKEN_MISSING" || errorLower.includes("auth_token_missing")) + ) { hints.push( - "No password was provided for this test. If your OpenClaw gateway requires authentication, add the shared secret and test again.", + "No shared secret was provided for this test. If your OpenClaw gateway uses token/password auth, add the configured secret and test again.", ); } - if (errorLower.includes("rpc error")) { + if ( + sharedSecretProvided && + (detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || + errorLower.includes("auth_token_mismatch")) + ) { hints.push( - "The gateway returned an RPC error, which usually means the request reached the OpenClaw service. Re-check the shared secret and any gateway-side auth configuration.", + "The gateway rejected the provided auth material. Re-check the configured shared secret and confirm whether this gateway expects token auth, password auth, or a paired device token.", ); } @@ -318,15 +456,53 @@ function buildHints( ); } - if (parsedUrl.protocol === "wss:" && (websocketFailure || authFailure || sessionFailure)) { + if (detailCode === "PAIRING_REQUIRED") { + hints.push( + "The gateway is asking for device pairing approval. Approve the pending device with `openclaw devices list` and `openclaw devices approve `, then retry.", + ); + } + + if ( + detailCode?.startsWith("DEVICE_AUTH_") || + errorLower.includes("device identity required") || + errorLower.includes("device nonce") || + errorLower.includes("device signature") + ) { + hints.push( + "This gateway requires challenge-based device auth. Modern OpenClaw connections must wait for `connect.challenge`, sign it with a device identity, and send that identity back in `connect.params.device`.", + ); + } + + if ( + diagnostics.hostKind === "tailscale" && + (detailCode === "PAIRING_REQUIRED" || + detailCode?.startsWith("DEVICE_AUTH_") || + errorLower.includes("device identity")) + ) { + hints.push( + "OpenClaw treats tailnet and LAN connects as remote for pairing/device auth. Even on the same physical machine, a `*.ts.net` connection usually needs an approved device identity unless the gateway is explicitly configured for a trusted proxy flow.", + ); + } + + if (gatewayRecommendedNextStep) { + hints.push(`Gateway recommended next step: \`${gatewayRecommendedNextStep}\`.`); + } + + if (diagnostics.gatewayCanRetryWithDeviceToken) { + hints.push( + "The gateway reported that a retry with a cached device token could work. That only helps after the device has already been paired and a token was persisted.", + ); + } + + if (parsedUrl.protocol === "wss:" && (websocketFailure || handshakeFailure)) { hints.push( "Because this uses `wss://`, check any reverse proxy or Tailscale Serve setup too. It must preserve WebSocket upgrades and continue forwarding frames after the initial handshake.", ); } - if (diagnostics.observedNotifications.length > 0 && authFailure) { + if (diagnostics.observedNotifications.length > 0 && handshakeFailure) { hints.push( - "The gateway sent notifications before auth completed. Check the gateway logs around the same time to see why it never answered the `auth.authenticate` request.", + "The gateway sent events before `connect` completed. Check the gateway logs around the same time to see why it never answered the handshake successfully.", ); } @@ -351,6 +527,8 @@ export async function runOpenclawGatewayTest( let rpcId = 1; const serverInfo: { version?: string; sessionId?: string } = {}; const diagnostics: MutableGatewayDiagnostics = createDiagnostics(); + const earlyGatewayEvents: GatewayEnvelope[] = []; + let captureEarlyGatewayEvents = true; const pushStep = ( name: string, @@ -381,7 +559,7 @@ export async function runOpenclawGatewayTest( diagnostics, failedStepName, error, - Boolean(input.password), + Boolean(input.password?.trim()), ); const diagnosticsResult: TestOpenclawGatewayDiagnostics = { ...diagnostics, @@ -401,13 +579,109 @@ export async function runOpenclawGatewayTest( let parsedUrlForHints: URL | null = null; - const sendRpc = ( + 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<{ result?: unknown; error?: { code: number; message: string } }> => + ): Promise<{ payload?: unknown; error?: ParsedGatewayError }> => new Promise((resolve, reject) => { - const id = rpcId++; + const id = String(rpcId++); let settled = false; let timeout: ReturnType | undefined; @@ -428,21 +702,30 @@ export async function runOpenclawGatewayTest( }; const onMessage = (data: NodeWebSocket.Data) => { - try { - const message = JSON.parse(bufferToString(data)) as JsonRpcEnvelope; - if (typeof message.method === "string") { - pushUnique(diagnostics.observedNotifications, message.method); - } - if (message.id === id) { + 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.result !== undefined ? { result: message.result } : {}), - ...(message.error !== undefined ? { error: message.error } : {}), - }), + resolve( + message.payload !== undefined + ? { payload: message.payload } + : { payload: undefined }, + ), ); + return; } - } catch { - // Ignore non-JSON websocket messages from intermediaries. + + const parsedError = parseGatewayError(message.error); + recordGatewayError(diagnostics, parsedError); + settle(() => resolve({ error: parsedError })); } }; @@ -456,7 +739,7 @@ export async function runOpenclawGatewayTest( settle(() => reject( new Error( - `WebSocket closed before RPC '${method}' completed${ + `WebSocket closed before gateway request '${method}' completed${ closeDetail ? ` (${closeDetail})` : "" }.`, ), @@ -467,7 +750,11 @@ export async function runOpenclawGatewayTest( const onError = (cause: Error) => { diagnostics.socketError = toMessage(cause, "WebSocket error."); settle(() => - reject(new Error(`WebSocket error during RPC '${method}': ${diagnostics.socketError}`)), + reject( + new Error( + `WebSocket error during gateway request '${method}': ${diagnostics.socketError}`, + ), + ), ); }; @@ -476,16 +763,18 @@ export async function runOpenclawGatewayTest( socket.on("error", onError); timeout = setTimeout(() => { - settle(() => reject(new Error(buildTimeoutDetail(method, diagnostics)))); + settle(() => + reject(new Error(buildTimeoutDetail(`Gateway request '${method}'`, diagnostics))), + ); }, OPENCLAW_TEST_RPC_TIMEOUT_MS); try { socket.send( JSON.stringify({ - jsonrpc: "2.0", + type: "req", + id, method, ...(params !== undefined ? { params } : {}), - id, }), ); } catch (cause) { @@ -494,9 +783,34 @@ export async function runOpenclawGatewayTest( } }); + 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(); + const sharedSecret = input.password?.trim() || undefined; if (!gatewayUrl) { pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); return finalize(false, "Gateway URL is empty.", "URL validation"); @@ -540,6 +854,18 @@ export async function runOpenclawGatewayTest( 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`)); @@ -564,16 +890,6 @@ export async function runOpenclawGatewayTest( ws.on("error", (cause: Error) => { diagnostics.socketError = toMessage(cause, "WebSocket error."); }); - ws.on("message", (data: NodeWebSocket.Data) => { - try { - const message = JSON.parse(bufferToString(data)) as JsonRpcEnvelope; - if (typeof message.method === "string") { - pushUnique(diagnostics.observedNotifications, message.method); - } - } catch { - // Ignore non-JSON websocket messages from intermediaries. - } - }); pushStep( "WebSocket connect", "pass", @@ -589,61 +905,26 @@ export async function runOpenclawGatewayTest( applyHealthProbe(await healthPromise); - if (input.password) { - const authStart = Date.now(); - try { - const authResult = await sendRpc(ws, "auth.authenticate", { - password: input.password, - }); - if (authResult.error) { - const detail = `RPC error ${authResult.error.code}: ${authResult.error.message}`; - pushStep("Authentication", "fail", Date.now() - authStart, detail); - return finalize( - false, - `Authentication failed: ${authResult.error.message}`, - "Authentication", - ); - } - pushStep("Authentication", "pass", Date.now() - authStart, "Authenticated."); - } catch (cause) { - const detail = toMessage(cause, "Authentication request failed."); - pushStep("Authentication", "fail", Date.now() - authStart, detail); - return finalize(false, detail, "Authentication"); - } - } - - const sessionStart = Date.now(); + const handshakeStart = Date.now(); try { - const sessionResult = await sendRpc(ws, "session.create"); - if (sessionResult.error) { - const detail = `RPC error ${sessionResult.error.code}: ${sessionResult.error.message}`; - pushStep("Session create", "fail", Date.now() - sessionStart, detail); - return finalize( - false, - `Session creation failed: ${sessionResult.error.message}`, - "Session create", - ); - } - - const result = (sessionResult.result ?? {}) as Record; - const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; - const version = typeof result.version === "string" ? result.version : undefined; - if (version !== undefined) { - serverInfo.version = version; - } - if (sessionId !== undefined) { - serverInfo.sessionId = sessionId; - } - pushStep( - "Session create", - "pass", - Date.now() - sessionStart, - sessionId ? `Session ID: ${sessionId}` : "Session created.", + 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, "Session creation failed."); - pushStep("Session create", "fail", Date.now() - sessionStart, detail); - return finalize(false, detail, "Session create"); + const detail = toMessage(cause, "Gateway handshake failed."); + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); } return finalize(true); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 9a404367..ca0ce0b9 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -236,8 +236,25 @@ function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): st if (diagnostics.socketError) { lines.push(`- Socket error: ${diagnostics.socketError}`); } + if (diagnostics.gatewayErrorCode) { + lines.push(`- Gateway error code: ${diagnostics.gatewayErrorCode}`); + } + if (diagnostics.gatewayErrorDetailCode) { + lines.push(`- Gateway detail code: ${diagnostics.gatewayErrorDetailCode}`); + } + if (diagnostics.gatewayErrorDetailReason) { + lines.push(`- Gateway detail reason: ${diagnostics.gatewayErrorDetailReason}`); + } + if (diagnostics.gatewayRecommendedNextStep) { + lines.push(`- Gateway next step: ${diagnostics.gatewayRecommendedNextStep}`); + } + if (diagnostics.gatewayCanRetryWithDeviceToken !== undefined) { + lines.push( + `- Device-token retry available: ${diagnostics.gatewayCanRetryWithDeviceToken ? "yes" : "no"}`, + ); + } if (diagnostics.observedNotifications.length > 0) { - lines.push(`- Gateway notifications: ${diagnostics.observedNotifications.join(", ")}`); + lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); } if (diagnostics.hints.length > 0) { lines.push(""); @@ -2621,10 +2638,54 @@ function SettingsRouteView() { )} + {openclawTestResult.diagnostics.gatewayErrorCode && ( +
+ Gateway error code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailCode && ( +
+ Gateway detail code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailReason && ( +
+ Gateway detail reason:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailReason} + +
+ )} + {openclawTestResult.diagnostics.gatewayRecommendedNextStep && ( +
+ Gateway next step:{" "} + + {openclawTestResult.diagnostics.gatewayRecommendedNextStep} + +
+ )} + {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== + undefined && ( +
+ Device-token retry available:{" "} + + {openclawTestResult.diagnostics + .gatewayCanRetryWithDeviceToken + ? "Yes" + : "No"} + +
+ )} {openclawTestResult.diagnostics.observedNotifications.length > 0 && (
- Gateway notifications:{" "} + Gateway events:{" "} {openclawTestResult.diagnostics.observedNotifications.join( ", ", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e436de05..8b5e49c9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -176,6 +176,11 @@ export const TestOpenclawGatewayDiagnostics = Schema.Struct({ socketCloseCode: Schema.optional(Schema.Number), socketCloseReason: Schema.optional(Schema.String), socketError: Schema.optional(Schema.String), + gatewayErrorCode: Schema.optional(Schema.String), + gatewayErrorDetailCode: Schema.optional(Schema.String), + gatewayErrorDetailReason: Schema.optional(Schema.String), + gatewayRecommendedNextStep: Schema.optional(Schema.String), + gatewayCanRetryWithDeviceToken: Schema.optional(Schema.Boolean), observedNotifications: Schema.Array(Schema.String), hints: Schema.Array(Schema.String), });