@@ -98,6 +98,253 @@ import { TokenManager } from "./tokenManager.ts";
9898import { resolveRuntimeEnvironment , RuntimeEnv } from "./runtimeEnvironment.ts" ;
9999import { version as serverVersion } from "../package.json" with { type : "json" } ;
100100import { serverBuildInfo } from "./buildInfo" ;
101+ import type { TestOpenclawGatewayInput , TestOpenclawGatewayResult , TestOpenclawGatewayStep } from "@okcode/contracts" ;
102+ import NodeWebSocket from "ws" ;
103+
104+ // ── OpenClaw Gateway Connection Test ──────────────────────────────────
105+
106+ const OPENCLAW_TEST_CONNECT_TIMEOUT_MS = 10_000 ;
107+ const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000 ;
108+
109+ function testOpenclawGateway (
110+ input : TestOpenclawGatewayInput ,
111+ ) : Effect . Effect < TestOpenclawGatewayResult > {
112+ return Effect . gen ( function * ( ) {
113+ const overallStart = Date . now ( ) ;
114+ const steps : TestOpenclawGatewayStep [ ] = [ ] ;
115+ let ws : NodeWebSocket | null = null ;
116+ let rpcId = 1 ;
117+ let serverInfo : { version ?: string ; sessionId ?: string } | undefined ;
118+
119+ const pushStep = (
120+ name : string ,
121+ status : "pass" | "fail" | "skip" ,
122+ durationMs : number ,
123+ detail ?: string ,
124+ ) => {
125+ steps . push ( { name, status, durationMs, ...( detail ? { detail } : { } ) } ) ;
126+ } ;
127+
128+ // ── Helper: send a JSON-RPC 2.0 request and wait for a response ──
129+ const sendRpc = (
130+ socket : NodeWebSocket ,
131+ method : string ,
132+ params ?: Record < string , unknown > ,
133+ ) : Promise < { result ?: unknown ; error ?: { code : number ; message : string } } > =>
134+ new Promise ( ( resolve , reject ) => {
135+ const id = rpcId ++ ;
136+ const timeout = setTimeout (
137+ ( ) => reject ( new Error ( `RPC '${ method } ' timed out after ${ OPENCLAW_TEST_RPC_TIMEOUT_MS } ms` ) ) ,
138+ OPENCLAW_TEST_RPC_TIMEOUT_MS ,
139+ ) ;
140+
141+ const handler = ( data : NodeWebSocket . Data ) => {
142+ try {
143+ const msg = JSON . parse ( String ( data ) ) as {
144+ id ?: number ;
145+ result ?: unknown ;
146+ error ?: { code : number ; message : string } ;
147+ } ;
148+ if ( msg . id === id ) {
149+ clearTimeout ( timeout ) ;
150+ socket . off ( "message" , handler ) ;
151+ resolve ( { result : msg . result , error : msg . error } ) ;
152+ }
153+ } catch {
154+ // Ignore non-JSON messages
155+ }
156+ } ;
157+
158+ socket . on ( "message" , handler ) ;
159+ socket . send (
160+ JSON . stringify ( {
161+ jsonrpc : "2.0" ,
162+ method,
163+ ...( params !== undefined ? { params } : { } ) ,
164+ id,
165+ } ) ,
166+ ) ;
167+ } ) ;
168+
169+ try {
170+ // ── Step 1: URL validation ──────────────────────────────────────
171+ const urlStart = Date . now ( ) ;
172+ const gatewayUrl = input . gatewayUrl . trim ( ) ;
173+ if ( ! gatewayUrl ) {
174+ pushStep ( "URL validation" , "fail" , Date . now ( ) - urlStart , "Gateway URL is empty." ) ;
175+ return {
176+ success : false ,
177+ steps,
178+ totalDurationMs : Date . now ( ) - overallStart ,
179+ error : "Gateway URL is empty." ,
180+ } ;
181+ }
182+ try {
183+ const parsed = new URL ( gatewayUrl ) ;
184+ if ( ! [ "ws:" , "wss:" ] . includes ( parsed . protocol ) ) {
185+ pushStep (
186+ "URL validation" ,
187+ "fail" ,
188+ Date . now ( ) - urlStart ,
189+ `Invalid protocol "${ parsed . protocol } ". Expected ws: or wss:.` ,
190+ ) ;
191+ return {
192+ success : false ,
193+ steps,
194+ totalDurationMs : Date . now ( ) - overallStart ,
195+ error : `Invalid protocol "${ parsed . protocol } ".` ,
196+ } ;
197+ }
198+ pushStep (
199+ "URL validation" ,
200+ "pass" ,
201+ Date . now ( ) - urlStart ,
202+ `${ parsed . protocol } //${ parsed . host } ` ,
203+ ) ;
204+ } catch {
205+ pushStep ( "URL validation" , "fail" , Date . now ( ) - urlStart , "Malformed URL." ) ;
206+ return {
207+ success : false ,
208+ steps,
209+ totalDurationMs : Date . now ( ) - overallStart ,
210+ error : "Malformed URL." ,
211+ } ;
212+ }
213+
214+ // ── Step 2: WebSocket connect ───────────────────────────────────
215+ const connectStart = Date . now ( ) ;
216+ try {
217+ ws = yield * Effect . tryPromise ( ( ) =>
218+ new Promise < NodeWebSocket > ( ( resolve , reject ) => {
219+ const socket = new NodeWebSocket ( gatewayUrl ) ;
220+ const timeout = setTimeout ( ( ) => {
221+ socket . close ( ) ;
222+ reject (
223+ new Error (
224+ `Connection timed out after ${ OPENCLAW_TEST_CONNECT_TIMEOUT_MS } ms` ,
225+ ) ,
226+ ) ;
227+ } , OPENCLAW_TEST_CONNECT_TIMEOUT_MS ) ;
228+
229+ socket . on ( "open" , ( ) => {
230+ clearTimeout ( timeout ) ;
231+ resolve ( socket ) ;
232+ } ) ;
233+ socket . on ( "error" , ( err ) => {
234+ clearTimeout ( timeout ) ;
235+ reject ( err ) ;
236+ } ) ;
237+ } ) ,
238+ ) ;
239+ pushStep (
240+ "WebSocket connect" ,
241+ "pass" ,
242+ Date . now ( ) - connectStart ,
243+ `Connected in ${ Date . now ( ) - connectStart } ms` ,
244+ ) ;
245+ } catch ( err ) {
246+ const detail =
247+ err instanceof Error ? err . message : "Connection failed." ;
248+ pushStep ( "WebSocket connect" , "fail" , Date . now ( ) - connectStart , detail ) ;
249+ return {
250+ success : false ,
251+ steps,
252+ totalDurationMs : Date . now ( ) - overallStart ,
253+ error : detail ,
254+ } ;
255+ }
256+
257+ // ── Step 3: Authentication ──────────────────────────────────────
258+ if ( input . password ) {
259+ const authStart = Date . now ( ) ;
260+ try {
261+ const response = yield * Effect . tryPromise ( ( ) =>
262+ sendRpc ( ws ! , "auth.authenticate" , { password : input . password } ) ,
263+ ) ;
264+ if ( response . error ) {
265+ pushStep (
266+ "Authentication" ,
267+ "fail" ,
268+ Date . now ( ) - authStart ,
269+ `RPC error ${ response . error . code } : ${ response . error . message } ` ,
270+ ) ;
271+ return {
272+ success : false ,
273+ steps,
274+ totalDurationMs : Date . now ( ) - overallStart ,
275+ error : `Authentication failed: ${ response . error . message } ` ,
276+ } ;
277+ }
278+ pushStep ( "Authentication" , "pass" , Date . now ( ) - authStart , "Authenticated successfully." ) ;
279+ } catch ( err ) {
280+ const detail = err instanceof Error ? err . message : "Authentication request failed." ;
281+ pushStep ( "Authentication" , "fail" , Date . now ( ) - authStart , detail ) ;
282+ return {
283+ success : false ,
284+ steps,
285+ totalDurationMs : Date . now ( ) - overallStart ,
286+ error : detail ,
287+ } ;
288+ }
289+ } else {
290+ pushStep ( "Authentication" , "skip" , 0 , "No password configured." ) ;
291+ }
292+
293+ // ── Step 4: Session create (probe) ──────────────────────────────
294+ const sessionStart = Date . now ( ) ;
295+ try {
296+ const response = yield * Effect . tryPromise ( ( ) =>
297+ sendRpc ( ws ! , "session.create" , { runtimeMode : "headless" } ) ,
298+ ) ;
299+ if ( response . error ) {
300+ pushStep (
301+ "Session create" ,
302+ "fail" ,
303+ Date . now ( ) - sessionStart ,
304+ `RPC error ${ response . error . code } : ${ response . error . message } ` ,
305+ ) ;
306+ return {
307+ success : false ,
308+ steps,
309+ totalDurationMs : Date . now ( ) - overallStart ,
310+ error : `Session creation failed: ${ response . error . message } ` ,
311+ } ;
312+ }
313+ const result = ( response . result ?? { } ) as Record < string , unknown > ;
314+ const sessionId = typeof result . sessionId === "string" ? result . sessionId : undefined ;
315+ const version = typeof result . version === "string" ? result . version : undefined ;
316+ serverInfo = { version, sessionId } ;
317+ pushStep (
318+ "Session create" ,
319+ "pass" ,
320+ Date . now ( ) - sessionStart ,
321+ sessionId ? `Session ID: ${ sessionId } ` : "Session created." ,
322+ ) ;
323+ } catch ( err ) {
324+ const detail = err instanceof Error ? err . message : "Session creation failed." ;
325+ pushStep ( "Session create" , "fail" , Date . now ( ) - sessionStart , detail ) ;
326+ return {
327+ success : false ,
328+ steps,
329+ totalDurationMs : Date . now ( ) - overallStart ,
330+ error : detail ,
331+ } ;
332+ }
333+
334+ return {
335+ success : true ,
336+ steps,
337+ totalDurationMs : Date . now ( ) - overallStart ,
338+ ...( serverInfo ? { serverInfo } : { } ) ,
339+ } ;
340+ } finally {
341+ // Always close the test WebSocket.
342+ if ( ws && ws . readyState === NodeWebSocket . OPEN ) {
343+ ws . close ( ) ;
344+ }
345+ }
346+ } ) ;
347+ }
101348
102349/**
103350 * Returns true if `a` is a strictly higher semver than `b`.
@@ -1535,6 +1782,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
15351782 return { tokens } ;
15361783 }
15371784
1785+ // ── OpenClaw gateway test ────────────────────────────────────────
1786+ case WS_METHODS . serverTestOpenclawGateway : {
1787+ const body = stripRequestTag ( request . body ) ;
1788+ return yield * testOpenclawGateway ( body ) ;
1789+ }
1790+
15381791 // ── Connection health ───────────────────────────────────────────
15391792 case WS_METHODS . serverPing :
15401793 return { pong : true , serverTime : Date . now ( ) } ;
0 commit comments