diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index af920373..4e5240ee 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -98,6 +98,253 @@ import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; import { serverBuildInfo } from "./buildInfo"; +import type { TestOpenclawGatewayInput, TestOpenclawGatewayResult, TestOpenclawGatewayStep } from "@okcode/contracts"; +import NodeWebSocket from "ws"; + +// ── OpenClaw Gateway Connection Test ────────────────────────────────── + +const OPENCLAW_TEST_CONNECT_TIMEOUT_MS = 10_000; +const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000; + +function testOpenclawGateway( + input: TestOpenclawGatewayInput, +): Effect.Effect { + return Effect.gen(function* () { + const overallStart = Date.now(); + const steps: TestOpenclawGatewayStep[] = []; + let ws: NodeWebSocket | null = null; + let rpcId = 1; + let serverInfo: { version?: string; sessionId?: string } | undefined; + + const pushStep = ( + name: string, + status: "pass" | "fail" | "skip", + durationMs: number, + detail?: string, + ) => { + steps.push({ name, status, durationMs, ...(detail ? { detail } : {}) }); + }; + + // ── Helper: send a JSON-RPC 2.0 request and wait for a response ── + const sendRpc = ( + socket: NodeWebSocket, + method: string, + params?: Record, + ): Promise<{ result?: unknown; error?: { code: number; message: string } }> => + new Promise((resolve, reject) => { + const id = rpcId++; + const timeout = setTimeout( + () => reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)), + OPENCLAW_TEST_RPC_TIMEOUT_MS, + ); + + const handler = (data: NodeWebSocket.Data) => { + try { + const msg = JSON.parse(String(data)) as { + id?: number; + result?: unknown; + error?: { code: number; message: string }; + }; + if (msg.id === id) { + clearTimeout(timeout); + socket.off("message", handler); + resolve({ result: msg.result, error: msg.error }); + } + } catch { + // Ignore non-JSON messages + } + }; + + socket.on("message", handler); + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + id, + }), + ); + }); + + try { + // ── Step 1: URL validation ────────────────────────────────────── + const urlStart = Date.now(); + const gatewayUrl = input.gatewayUrl.trim(); + if (!gatewayUrl) { + pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: "Gateway URL is empty.", + }; + } + try { + const parsed = new URL(gatewayUrl); + if (!["ws:", "wss:"].includes(parsed.protocol)) { + pushStep( + "URL validation", + "fail", + Date.now() - urlStart, + `Invalid protocol "${parsed.protocol}". Expected ws: or wss:.`, + ); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: `Invalid protocol "${parsed.protocol}".`, + }; + } + pushStep( + "URL validation", + "pass", + Date.now() - urlStart, + `${parsed.protocol}//${parsed.host}`, + ); + } catch { + pushStep("URL validation", "fail", Date.now() - urlStart, "Malformed URL."); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: "Malformed URL.", + }; + } + + // ── Step 2: WebSocket connect ─────────────────────────────────── + const connectStart = Date.now(); + try { + ws = yield* Effect.tryPromise(() => + new Promise((resolve, reject) => { + const socket = new NodeWebSocket(gatewayUrl); + 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", (err) => { + clearTimeout(timeout); + reject(err); + }); + }), + ); + pushStep( + "WebSocket connect", + "pass", + Date.now() - connectStart, + `Connected in ${Date.now() - connectStart}ms`, + ); + } catch (err) { + const detail = + err instanceof Error ? err.message : "Connection failed."; + pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: detail, + }; + } + + // ── Step 3: Authentication ────────────────────────────────────── + if (input.password) { + const authStart = Date.now(); + try { + const response = yield* Effect.tryPromise(() => + sendRpc(ws!, "auth.authenticate", { password: input.password }), + ); + if (response.error) { + pushStep( + "Authentication", + "fail", + Date.now() - authStart, + `RPC error ${response.error.code}: ${response.error.message}`, + ); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: `Authentication failed: ${response.error.message}`, + }; + } + pushStep("Authentication", "pass", Date.now() - authStart, "Authenticated successfully."); + } catch (err) { + const detail = err instanceof Error ? err.message : "Authentication request failed."; + pushStep("Authentication", "fail", Date.now() - authStart, detail); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: detail, + }; + } + } else { + pushStep("Authentication", "skip", 0, "No password configured."); + } + + // ── Step 4: Session create (probe) ────────────────────────────── + const sessionStart = Date.now(); + try { + const response = yield* Effect.tryPromise(() => + sendRpc(ws!, "session.create", { runtimeMode: "headless" }), + ); + if (response.error) { + pushStep( + "Session create", + "fail", + Date.now() - sessionStart, + `RPC error ${response.error.code}: ${response.error.message}`, + ); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: `Session creation failed: ${response.error.message}`, + }; + } + const result = (response.result ?? {}) as Record; + const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; + const version = typeof result.version === "string" ? result.version : undefined; + serverInfo = { version, sessionId }; + pushStep( + "Session create", + "pass", + Date.now() - sessionStart, + sessionId ? `Session ID: ${sessionId}` : "Session created.", + ); + } catch (err) { + const detail = err instanceof Error ? err.message : "Session creation failed."; + pushStep("Session create", "fail", Date.now() - sessionStart, detail); + return { + success: false, + steps, + totalDurationMs: Date.now() - overallStart, + error: detail, + }; + } + + return { + success: true, + steps, + totalDurationMs: Date.now() - overallStart, + ...(serverInfo ? { serverInfo } : {}), + }; + } finally { + // Always close the test WebSocket. + if (ws && ws.readyState === NodeWebSocket.OPEN) { + ws.close(); + } + } + }); +} /** * Returns true if `a` is a strictly higher semver than `b`. @@ -1535,6 +1782,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { tokens }; } + // ── OpenClaw gateway test ──────────────────────────────────────── + case WS_METHODS.serverTestOpenclawGateway: { + const body = stripRequestTag(request.body); + return yield* testOpenclawGateway(body); + } + // ── Connection health ─────────────────────────────────────────── case WS_METHODS.serverPing: return { pong: true, serverTime: Date.now() }; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7efaee74..155fb7e2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,14 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { + CheckCircle2Icon, ChevronDownIcon, ImportIcon, + Loader2Icon, PlusIcon, RotateCcwIcon, + SkipForwardIcon, Undo2Icon, + XCircleIcon, XIcon, } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; +import type { TestOpenclawGatewayResult } from "@okcode/contracts"; import { type ProjectId, type ProviderKind, @@ -370,6 +375,10 @@ function SettingsRouteView() { const [fontSizeOverride, setFontSizeOverrideState] = useState(() => getStoredFontSizeOverride(), ); + const [openclawTestResult, setOpenclawTestResult] = + useState(null); + const [openclawTestLoading, setOpenclawTestLoading] = useState(false); + const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null; @@ -542,6 +551,29 @@ function SettingsRouteView() { [queryClient, selectedProject], ); + const testOpenclawGateway = useCallback(async () => { + if (openclawTestLoading) return; + setOpenclawTestLoading(true); + setOpenclawTestResult(null); + try { + const api = ensureNativeApi(); + const result = await api.server.testOpenclawGateway({ + gatewayUrl: settings.openclawGatewayUrl, + password: settings.openclawPassword || undefined, + }); + setOpenclawTestResult(result); + } catch (err) { + setOpenclawTestResult({ + success: false, + steps: [], + totalDurationMs: 0, + error: err instanceof Error ? err.message : "Unexpected error during test.", + }); + } finally { + setOpenclawTestLoading(false); + } + }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -2051,9 +2083,10 @@ function SettingsRouteView() { id="openclaw-gateway-url" className="mt-1" value={settings.openclawGatewayUrl} - onChange={(event) => - updateSettings({ openclawGatewayUrl: event.target.value }) - } + onChange={(event) => { + updateSettings({ openclawGatewayUrl: event.target.value }); + setOpenclawTestResult(null); + }} placeholder="ws://localhost:8080" spellCheck={false} /> @@ -2068,7 +2101,10 @@ function SettingsRouteView() { className="mt-1" type="password" value={settings.openclawPassword} - onChange={(event) => updateSettings({ openclawPassword: event.target.value })} + onChange={(event) => { + updateSettings({ openclawPassword: event.target.value }); + setOpenclawTestResult(null); + }} placeholder="Shared secret" spellCheck={false} autoComplete="off" @@ -2077,6 +2113,125 @@ function SettingsRouteView() { Shared secret used to authenticate with the gateway. + + {/* Test Connection Button */} +
+ +
+ + {/* Debug / Results Panel */} + {openclawTestResult && ( +
+ {/* Overall status header */} +
+ {openclawTestResult.success ? ( + + ) : ( + + )} + + {openclawTestResult.success + ? "Connection successful" + : "Connection failed"} + + + {openclawTestResult.totalDurationMs}ms total + +
+ + {/* Step-by-step results */} + {openclawTestResult.steps.length > 0 && ( +
+ {openclawTestResult.steps.map((step, i) => ( +
+ {step.status === "pass" && ( + + )} + {step.status === "fail" && ( + + )} + {step.status === "skip" && ( + + )} +
+
+ + {step.name} + + + {step.durationMs}ms + +
+ {step.detail && ( + + {step.detail} + + )} +
+
+ ))} +
+ )} + + {/* Server info */} + {openclawTestResult.serverInfo && ( +
+ + Server Info + +
+ {openclawTestResult.serverInfo.version && ( +
+ Version:{" "} + + {openclawTestResult.serverInfo.version} + +
+ )} + {openclawTestResult.serverInfo.sessionId && ( +
+ Session:{" "} + + {openclawTestResult.serverInfo.sessionId} + +
+ )} +
+
+ )} + + {/* Error summary */} + {openclawTestResult.error && + !openclawTestResult.steps.some( + (s) => s.status === "fail", + ) && ( +
+ {openclawTestResult.error} +
+ )} +
+ )} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 6a336659..b2591e67 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -369,6 +369,8 @@ export function createWsNativeApi(): NativeApi { saveProjectEnvironmentVariables: (input) => transport.request(WS_METHODS.serverSaveProjectEnvironmentVariables, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + testOpenclawGateway: (input) => + transport.request(WS_METHODS.serverTestOpenclawGateway, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88f62fd2..355f9887 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -70,7 +70,7 @@ import type { GitHubPostCommentInput, GitHubPostCommentResult, } from "./github"; -import type { ServerConfig } from "./server"; +import type { ServerConfig, TestOpenclawGatewayInput, TestOpenclawGatewayResult } from "./server"; import type { GlobalEnvironmentVariablesResult, ProjectEnvironmentVariablesInput, @@ -432,6 +432,9 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + testOpenclawGateway: ( + input: TestOpenclawGatewayInput, + ) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8ccdb267..c5fed39e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -134,3 +134,36 @@ export const ListTokensResult = Schema.Struct({ tokens: Schema.Array(PairingTokenInfo), }); export type ListTokensResult = typeof ListTokensResult.Type; + +// ── OpenClaw Gateway Test ─────────────────────────────────────────── + +export const TestOpenclawGatewayInput = Schema.Struct({ + gatewayUrl: Schema.String, + password: Schema.optional(Schema.String), +}); +export type TestOpenclawGatewayInput = typeof TestOpenclawGatewayInput.Type; + +/** Individual step result in the gateway connection test. */ +export const TestOpenclawGatewayStep = Schema.Struct({ + name: Schema.String, + status: Schema.Literals(["pass", "fail", "skip"]), + durationMs: Schema.Number, + detail: Schema.optional(Schema.String), +}); +export type TestOpenclawGatewayStep = typeof TestOpenclawGatewayStep.Type; + +export const TestOpenclawGatewayResult = Schema.Struct({ + success: Schema.Boolean, + steps: Schema.Array(TestOpenclawGatewayStep), + /** Total wall-clock time for the entire test sequence. */ + totalDurationMs: Schema.Number, + /** Gateway-reported server info, if available. */ + serverInfo: Schema.optional( + Schema.Struct({ + version: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + }), + ), + error: Schema.optional(Schema.String), +}); +export type TestOpenclawGatewayResult = typeof TestOpenclawGatewayResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index de64562e..6090b7ee 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -68,7 +68,12 @@ import { } from "./project"; import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; -import { GeneratePairingLinkInput, RevokeTokenInput, ServerConfigUpdatedPayload } from "./server"; +import { + GeneratePairingLinkInput, + RevokeTokenInput, + ServerConfigUpdatedPayload, + TestOpenclawGatewayInput, +} from "./server"; import { GitHubGetIssueInput, GitHubListIssuesInput, GitHubPostCommentInput } from "./github"; import { SkillListInput, @@ -172,6 +177,9 @@ export const WS_METHODS = { serverRevokeToken: "server.revokeToken", serverListTokens: "server.listTokens", + // OpenClaw gateway + serverTestOpenclawGateway: "server.testOpenclawGateway", + // Connection health serverPing: "server.ping", } as const; @@ -304,6 +312,9 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverRevokeToken, RevokeTokenInput), tagRequestBody(WS_METHODS.serverListTokens, Schema.Struct({})), + // OpenClaw gateway + tagRequestBody(WS_METHODS.serverTestOpenclawGateway, TestOpenclawGatewayInput), + // Connection health tagRequestBody(WS_METHODS.serverPing, Schema.Struct({})), ]);