


























1+import { randomUUID } from "node:crypto";
2+import fs from "node:fs/promises";
3+import os from "node:os";
4+import path from "node:path";
5+import { WebSocket } from "ws";
6+import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js";
7+import { buildDeviceAuthPayloadV3 } from "../../src/gateway/device-auth.js";
8+import { startGatewayServer } from "../../src/gateway/server.js";
9+import {
10+loadOrCreateDeviceIdentity,
11+publicKeyRawBase64UrlFromPem,
12+signDevicePayload,
13+} from "../../src/infra/device-identity.js";
14+15+async function getFreePort(): Promise<number> {
16+const net = await import("node:net");
17+return await new Promise((resolve, reject) => {
18+const srv = net.createServer();
19+srv.listen(0, "127.0.0.1", () => {
20+const addr = srv.address();
21+if (addr && typeof addr === "object") {
22+const port = addr.port;
23+srv.close(() => resolve(port));
24+} else {
25+srv.close(() => reject(new Error("could not determine free port")));
26+}
27+});
28+srv.once("error", reject);
29+});
30+}
31+32+async function main() {
33+console.log("=== Reproduction for issue #90654 (WebSocket handshake) ===");
34+const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-repro-90654-ws-"));
35+console.log("Temp state dir:", tmpDir);
36+process.env.OPENCLAW_STATE_DIR = tmpDir;
37+process.env.OPENCLAW_SKIP_CHANNELS = "1";
38+process.env.OPENCLAW_SKIP_PROVIDERS = "1";
39+process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
40+process.env.VITEST = "true";
41+42+const config = {
43+gateway: {
44+auth: { mode: "none" },
45+controlUi: { enabled: false },
46+},
47+};
48+await fs.mkdir(path.join(tmpDir, "devices"), { recursive: true });
49+await fs.writeFile(path.join(tmpDir, "openclaw.json"), JSON.stringify(config, null, 2));
50+51+const identity = await loadOrCreateDeviceIdentity(path.join(tmpDir, "device-identity.json"));
52+const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
53+const deviceId = identity.deviceId;
54+55+const paired = {
56+[deviceId]: {
57+ deviceId,
58+ publicKey,
59+displayName: "Repro Device",
60+platform: "test",
61+deviceFamily: "test",
62+clientId: "openclaw-test",
63+clientMode: "test",
64+roles: ["operator", undefined, null, 42],
65+scopes: ["read", undefined, null, 42],
66+approvedScopes: ["read", undefined, null, 42],
67+tokens: {},
68+createdAtMs: Date.now(),
69+approvedAtMs: Date.now(),
70+},
71+};
72+await fs.writeFile(path.join(tmpDir, "devices", "paired.json"), JSON.stringify(paired));
73+74+const port = await getFreePort();
75+console.log(`Starting gateway on port ${port}...`);
76+const server = await startGatewayServer(port, {
77+auth: { mode: "none" },
78+bind: "loopback",
79+controlUiEnabled: false,
80+deferStartupSidecars: true,
81+});
82+console.log("Gateway started.");
83+84+// Give the server a moment to finish post-ready setup before connecting.
85+await new Promise<void>((resolve) => {
86+setTimeout(resolve, 500);
87+});
88+89+console.log(`Connecting WebSocket to port ${port}...`);
90+const ws = new WebSocket(`ws://127.0.0.1:${port}`);
91+let connectChallengeNonce: string | undefined;
92+93+ws.on("open", () => console.log("[ws] open"));
94+ws.on("error", (err) => console.log("[ws] error:", err.message));
95+ws.on("close", (code, reason) => console.log("[ws] close:", code, reason.toString()));
96+97+const response = await new Promise<unknown>((resolve, reject) => {
98+const timer = setTimeout(
99+() => reject(new Error("timeout waiting for connect response")),
100+15_000,
101+);
102+103+ws.on("message", (data) => {
104+const text = data.toString();
105+console.log("[ws] message:", text.slice(0, 500));
106+let frame: unknown;
107+try {
108+frame = JSON.parse(text);
109+} catch {
110+return;
111+}
112+const rec = frame as Record<string, unknown>;
113+const payload =
114+rec.payload && typeof rec.payload === "object"
115+ ? (rec.payload as Record<string, unknown>)
116+ : undefined;
117+if (
118+rec.type === "event" &&
119+rec.event === "connect.challenge" &&
120+payload &&
121+typeof payload.nonce === "string"
122+) {
123+connectChallengeNonce = payload.nonce;
124+console.log("Got challenge nonce:", connectChallengeNonce);
125+sendConnect();
126+return;
127+}
128+if (rec.type === "res") {
129+resolved = true;
130+clearTimeout(timer);
131+resolve(frame);
132+}
133+});
134+let resolved = false;
135+ws.once("error", (err) => {
136+clearTimeout(timer);
137+reject(err);
138+});
139+ws.once("close", (code, reason) => {
140+clearTimeout(timer);
141+// Give any in-flight response frame a moment to be delivered before treating close as failure.
142+setTimeout(() => {
143+if (!resolved) {
144+reject(new Error(`closed ${code}: ${reason.toString()}`));
145+}
146+}, 100);
147+});
148+149+async function sendConnect() {
150+const id = randomUUID();
151+const client = {
152+id: "test",
153+version: "1.0.0",
154+platform: "test",
155+deviceFamily: "test",
156+mode: "test",
157+};
158+const role = "operator"; // paired role is "operator"; scope mismatch triggers scope-upgrade audit
159+const scopes = ["write"]; // different from paired "read" to trigger scope-upgrade audit
160+const signedAtMs = Date.now();
161+console.log("Sending connect with role:", role, "scopes:", scopes);
162+const payload = buildDeviceAuthPayloadV3({
163+ deviceId,
164+clientId: client.id,
165+clientMode: client.mode,
166+ role,
167+ scopes,
168+ signedAtMs,
169+token: null,
170+nonce: connectChallengeNonce!,
171+platform: client.platform,
172+deviceFamily: client.deviceFamily,
173+});
174+const signature = signDevicePayload(identity.privateKeyPem, payload);
175+ws.send(
176+JSON.stringify({
177+type: "req",
178+ id,
179+method: "connect",
180+params: {
181+minProtocol: PROTOCOL_VERSION,
182+maxProtocol: PROTOCOL_VERSION,
183+ client,
184+caps: [],
185+commands: [],
186+ role,
187+ scopes,
188+device: {
189+id: deviceId,
190+ publicKey,
191+ signature,
192+signedAt: signedAtMs,
193+nonce: connectChallengeNonce,
194+},
195+},
196+}),
197+);
198+}
199+});
200+201+console.log("Connect response:", JSON.stringify(response, null, 2));
202+ws.close();
203+await server.close();
204+await fs.rm(tmpDir, { recursive: true, force: true });
205+console.log("PASS: Gateway WebSocket handshake did not crash with malformed pairing state.");
206+}
207+208+main().catch((err: unknown) => {
209+console.error("FAIL:", err);
210+process.exitCode = 1;
211+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。