Skip to content

Commit 9f82c4a

Browse files
authored
Add OpenClaw gateway connection test (#361)
- Add a server-side gateway probe with step-by-step results - Surface the test in Settings with status, timing, and server info - Wire the new RPC through the WS native API and contracts
1 parent 82a2c06 commit 9f82c4a

6 files changed

Lines changed: 463 additions & 6 deletions

File tree

apps/server/src/wsServer.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,253 @@ import { TokenManager } from "./tokenManager.ts";
9898
import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts";
9999
import { version as serverVersion } from "../package.json" with { type: "json" };
100100
import { 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

Comments
 (0)