@castlekit/castle 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { EventEmitter } from "events";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { readFileSync, realpathSync } from "fs";
|
|
6
|
+
import { dirname, join } from "path";
|
|
4
7
|
import { getGatewayUrl, readOpenClawToken, readConfig, configExists } from "./config";
|
|
8
|
+
import { getOrCreateIdentity, signDeviceAuth, saveDeviceToken, getDeviceToken, clearDeviceToken } from "./device-identity";
|
|
5
9
|
|
|
6
10
|
// ============================================================================
|
|
7
11
|
// Types
|
|
@@ -44,7 +48,7 @@ interface PendingRequest {
|
|
|
44
48
|
timer: ReturnType<typeof setTimeout>;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export type ConnectionState = "disconnected" | "connecting" | "connected" | "error";
|
|
51
|
+
export type ConnectionState = "disconnected" | "connecting" | "connected" | "pairing" | "error";
|
|
48
52
|
|
|
49
53
|
export interface GatewayEvent {
|
|
50
54
|
event: string;
|
|
@@ -52,6 +56,21 @@ export interface GatewayEvent {
|
|
|
52
56
|
seq?: number;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Helpers
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
64
|
+
const MAX_NONCE_LENGTH = 1024;
|
|
65
|
+
|
|
66
|
+
/** Strip tokens and key material from strings before logging. */
|
|
67
|
+
function sanitize(str: string): string {
|
|
68
|
+
return str
|
|
69
|
+
.replace(/rew_[a-f0-9]+/gi, "rew_***")
|
|
70
|
+
.replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[REDACTED KEY]")
|
|
71
|
+
.replace(/[a-f0-9]{32,}/gi, (m) => m.slice(0, 8) + "***");
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
// ============================================================================
|
|
56
75
|
// Singleton Gateway Connection
|
|
57
76
|
// ============================================================================
|
|
@@ -69,6 +88,10 @@ class GatewayConnection extends EventEmitter {
|
|
|
69
88
|
private _serverInfo: { version?: string; connId?: string } = {};
|
|
70
89
|
private _features: { methods?: string[]; events?: string[] } = {};
|
|
71
90
|
private shouldReconnect = true;
|
|
91
|
+
// Device auth: set to true after "device identity mismatch" to fall back to token-only
|
|
92
|
+
private _skipDeviceAuth = false;
|
|
93
|
+
// Avatar URL mapping: hash → original Gateway URL (for proxying)
|
|
94
|
+
private _avatarUrls = new Map<string, string>();
|
|
72
95
|
|
|
73
96
|
get state(): ConnectionState {
|
|
74
97
|
return this._state;
|
|
@@ -78,6 +101,16 @@ class GatewayConnection extends EventEmitter {
|
|
|
78
101
|
return this._serverInfo;
|
|
79
102
|
}
|
|
80
103
|
|
|
104
|
+
/** Store a mapping from avatar hash to its original Gateway URL */
|
|
105
|
+
setAvatarUrl(hash: string, originalUrl: string): void {
|
|
106
|
+
this._avatarUrls.set(hash, originalUrl);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Get the original Gateway URL for an avatar hash */
|
|
110
|
+
getAvatarUrl(hash: string): string | null {
|
|
111
|
+
return this._avatarUrls.get(hash) || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
get isConnected(): boolean {
|
|
82
115
|
return this._state === "connected";
|
|
83
116
|
}
|
|
@@ -94,6 +127,7 @@ class GatewayConnection extends EventEmitter {
|
|
|
94
127
|
start(): void {
|
|
95
128
|
if (this._state === "connecting" || this._state === "connected") return;
|
|
96
129
|
this.shouldReconnect = true;
|
|
130
|
+
this._skipDeviceAuth = false; // Reset — try device auth on fresh start
|
|
97
131
|
this.connect();
|
|
98
132
|
}
|
|
99
133
|
|
|
@@ -119,7 +153,7 @@ class GatewayConnection extends EventEmitter {
|
|
|
119
153
|
try {
|
|
120
154
|
this.ws = new WebSocket(url);
|
|
121
155
|
} catch (err) {
|
|
122
|
-
console.error("[Gateway] Failed to create WebSocket:", err);
|
|
156
|
+
console.error("[Gateway] Failed to create WebSocket:", (err as Error).message);
|
|
123
157
|
this._state = "error";
|
|
124
158
|
this.emit("stateChange", this._state);
|
|
125
159
|
this.scheduleReconnect();
|
|
@@ -132,66 +166,224 @@ class GatewayConnection extends EventEmitter {
|
|
|
132
166
|
this.scheduleReconnect();
|
|
133
167
|
}, this.connectTimeout);
|
|
134
168
|
|
|
169
|
+
// Load device identity for auth (skip if previously rejected)
|
|
170
|
+
let deviceIdentity: { deviceId: string; publicKey: string } | null = null;
|
|
171
|
+
if (!this._skipDeviceAuth) {
|
|
172
|
+
try {
|
|
173
|
+
const identity = getOrCreateIdentity();
|
|
174
|
+
deviceIdentity = { deviceId: identity.deviceId, publicKey: identity.publicKey };
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn("[Gateway] Could not load device identity:", (err as Error).message);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
console.log("[Gateway] Device auth disabled — using token-only");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check for a saved device token from previous pairing
|
|
183
|
+
const savedDeviceToken = getDeviceToken();
|
|
184
|
+
|
|
135
185
|
this.ws.on("open", () => {
|
|
136
|
-
// Build connect handshake
|
|
137
186
|
const connectId = randomUUID();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
187
|
+
|
|
188
|
+
// Build connect frame per Gateway protocol:
|
|
189
|
+
// - Initial connect: token-only (no device field)
|
|
190
|
+
// - Challenge response: include device { id, publicKey, signature, signedAt, nonce }
|
|
191
|
+
// - Reconnect with deviceToken: use deviceToken as auth.token
|
|
192
|
+
//
|
|
193
|
+
// The device field is ONLY sent when responding to connect.challenge,
|
|
194
|
+
// because the Gateway requires signature + signedAt whenever device is present.
|
|
195
|
+
let challengeReceived = false;
|
|
196
|
+
|
|
197
|
+
// Connection identity constants — must match what's signed
|
|
198
|
+
const CLIENT_ID = "gateway-client";
|
|
199
|
+
const CLIENT_MODE = "backend";
|
|
200
|
+
const ROLE = "operator";
|
|
201
|
+
const SCOPES = ["operator.admin"];
|
|
202
|
+
const authToken = savedDeviceToken || token;
|
|
203
|
+
|
|
204
|
+
const buildConnectFrame = (challenge?: {
|
|
205
|
+
nonce: string;
|
|
206
|
+
signature: string;
|
|
207
|
+
signedAt: number;
|
|
208
|
+
}): RequestFrame => {
|
|
209
|
+
const params: Record<string, unknown> = {
|
|
143
210
|
minProtocol: 3,
|
|
144
211
|
maxProtocol: 3,
|
|
145
212
|
client: {
|
|
146
|
-
id:
|
|
213
|
+
id: CLIENT_ID,
|
|
147
214
|
displayName: "Castle",
|
|
148
215
|
version: "0.0.1",
|
|
149
216
|
platform: process.platform,
|
|
150
|
-
mode:
|
|
217
|
+
mode: CLIENT_MODE,
|
|
151
218
|
},
|
|
152
|
-
auth: { token },
|
|
153
|
-
role:
|
|
154
|
-
scopes:
|
|
219
|
+
auth: { token: authToken },
|
|
220
|
+
role: ROLE,
|
|
221
|
+
scopes: SCOPES,
|
|
155
222
|
caps: [],
|
|
156
|
-
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Only include device when responding to a challenge (with signature)
|
|
226
|
+
if (challenge && deviceIdentity) {
|
|
227
|
+
params.device = {
|
|
228
|
+
id: deviceIdentity.deviceId,
|
|
229
|
+
publicKey: deviceIdentity.publicKey,
|
|
230
|
+
signature: challenge.signature,
|
|
231
|
+
nonce: challenge.nonce,
|
|
232
|
+
signedAt: challenge.signedAt,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { type: "req", id: connectId, method: "connect", params };
|
|
157
237
|
};
|
|
158
238
|
|
|
159
|
-
// Handle handshake messages
|
|
239
|
+
// Handle handshake messages
|
|
160
240
|
const onHandshakeMessage = (data: WebSocket.RawData) => {
|
|
241
|
+
const rawSize = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data.toString());
|
|
242
|
+
if (rawSize > MAX_MESSAGE_SIZE) {
|
|
243
|
+
console.error(`[Gateway] Message too large (${rawSize} bytes) — ignoring`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
161
246
|
try {
|
|
162
247
|
const msg = JSON.parse(data.toString());
|
|
163
248
|
|
|
164
|
-
// Handle connect.challenge
|
|
249
|
+
// Handle connect.challenge — sign nonce and re-send connect with device identity
|
|
165
250
|
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
166
|
-
|
|
167
|
-
|
|
251
|
+
const nonce = msg.payload?.nonce;
|
|
252
|
+
if (nonce && typeof nonce === "string" && nonce.length > MAX_NONCE_LENGTH) {
|
|
253
|
+
console.error(`[Gateway] Challenge nonce too large (${nonce.length} bytes) — rejecting`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (nonce && typeof nonce === "string" && deviceIdentity) {
|
|
257
|
+
challengeReceived = true;
|
|
258
|
+
console.log("[Gateway] Challenge received — signing with device key");
|
|
259
|
+
try {
|
|
260
|
+
const { signature, signedAt } = signDeviceAuth({
|
|
261
|
+
nonce,
|
|
262
|
+
clientId: CLIENT_ID,
|
|
263
|
+
clientMode: CLIENT_MODE,
|
|
264
|
+
role: ROLE,
|
|
265
|
+
scopes: SCOPES,
|
|
266
|
+
token: authToken!,
|
|
267
|
+
});
|
|
268
|
+
const challengeFrame = buildConnectFrame({ nonce, signature, signedAt });
|
|
269
|
+
this.ws?.send(JSON.stringify(challengeFrame));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error("[Gateway] Failed to sign challenge:", (err as Error).message);
|
|
272
|
+
challengeReceived = false;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
console.log("[Gateway] Challenge received but no device identity — skipping");
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Handle device.pairing.required — waiting for operator approval
|
|
281
|
+
if (msg.type === "event" && msg.event === "device.pairing.required") {
|
|
282
|
+
console.log("[Gateway] Device pairing approval required");
|
|
283
|
+
console.log("[Gateway] Approve this device in your OpenClaw dashboard to continue...");
|
|
284
|
+
this._state = "pairing";
|
|
285
|
+
this.emit("stateChange", this._state);
|
|
286
|
+
this.emit("pairingRequired", msg.payload);
|
|
287
|
+
this.emit("gatewayEvent", {
|
|
288
|
+
event: msg.event,
|
|
289
|
+
payload: msg.payload,
|
|
290
|
+
seq: msg.seq,
|
|
291
|
+
} as GatewayEvent);
|
|
168
292
|
return;
|
|
169
293
|
}
|
|
170
294
|
|
|
171
|
-
//
|
|
295
|
+
// Handle device.pairing.approved — save the device token
|
|
296
|
+
if (msg.type === "event" && msg.event === "device.pairing.approved") {
|
|
297
|
+
const approvedToken = msg.payload?.deviceToken;
|
|
298
|
+
if (approvedToken && typeof approvedToken === "string") {
|
|
299
|
+
console.log("[Gateway] Device pairing approved — saving token");
|
|
300
|
+
try {
|
|
301
|
+
saveDeviceToken(approvedToken, url);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error("[Gateway] Failed to save device token:", (err as Error).message);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.emit("pairingApproved", msg.payload);
|
|
307
|
+
this.emit("gatewayEvent", {
|
|
308
|
+
event: msg.event,
|
|
309
|
+
payload: msg.payload,
|
|
310
|
+
seq: msg.seq,
|
|
311
|
+
} as GatewayEvent);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Standard response to our connect request
|
|
172
316
|
if (msg.type === "res" && msg.id === connectId) {
|
|
317
|
+
// If we already sent a challenge response, ignore error from the
|
|
318
|
+
// initial (token-only) connect — the signed response is in flight.
|
|
319
|
+
if (!msg.ok && challengeReceived) {
|
|
320
|
+
console.log("[Gateway] Ignoring error from initial connect — challenge response pending");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
173
324
|
clearTimeout(connectTimer);
|
|
174
325
|
|
|
175
326
|
if (msg.ok) {
|
|
176
|
-
// hello-ok is embedded in the payload
|
|
177
327
|
const helloOk = msg.payload || {};
|
|
328
|
+
|
|
329
|
+
// Save deviceToken from hello-ok (may be at payload.auth.deviceToken
|
|
330
|
+
// or payload.deviceToken depending on Gateway version)
|
|
331
|
+
const helloDeviceToken =
|
|
332
|
+
helloOk.auth?.deviceToken || helloOk.deviceToken;
|
|
333
|
+
if (helloDeviceToken && typeof helloDeviceToken === "string") {
|
|
334
|
+
try {
|
|
335
|
+
saveDeviceToken(helloDeviceToken, url);
|
|
336
|
+
console.log("[Gateway] Device token received and saved");
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error("[Gateway] Failed to save device token:", (err as Error).message);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
178
342
|
this._state = "connected";
|
|
179
343
|
this._serverInfo = helloOk.server || {};
|
|
344
|
+
// If Gateway reports "dev" or missing version, read from installed package.json
|
|
345
|
+
if (!this._serverInfo.version || this._serverInfo.version === "dev") {
|
|
346
|
+
try {
|
|
347
|
+
const bin = execSync("which openclaw", { timeout: 2000, encoding: "utf-8" }).trim();
|
|
348
|
+
const real = realpathSync(bin);
|
|
349
|
+
const pkgPath = join(dirname(real), "package.json");
|
|
350
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
351
|
+
if (pkg.version) this._serverInfo.version = pkg.version;
|
|
352
|
+
} catch { /* keep whatever the Gateway reported */ }
|
|
353
|
+
}
|
|
180
354
|
this._features = helloOk.features || {};
|
|
181
355
|
this.reconnectAttempts = 0;
|
|
182
356
|
this.emit("stateChange", this._state);
|
|
183
357
|
this.emit("connected", helloOk);
|
|
184
|
-
console.log(`[Gateway] Connected to OpenClaw
|
|
358
|
+
console.log(`[Gateway] Connected to OpenClaw ${this._serverInfo.version || "unknown"}`);
|
|
185
359
|
// Switch to normal message handler
|
|
186
360
|
this.ws?.off("message", onHandshakeMessage);
|
|
187
361
|
this.ws?.on("message", this.onMessage.bind(this));
|
|
188
362
|
} else {
|
|
363
|
+
const errCode = msg.error?.code;
|
|
189
364
|
const errMsg = msg.error?.message || "Connect rejected";
|
|
190
|
-
console.error(`[Gateway] Connect failed: ${errMsg}`);
|
|
365
|
+
console.error(`[Gateway] Connect failed: ${sanitize(errMsg)}`);
|
|
191
366
|
this.ws?.off("message", onHandshakeMessage);
|
|
192
367
|
this.cleanup();
|
|
193
|
-
|
|
194
|
-
if (
|
|
368
|
+
|
|
369
|
+
if (errCode === "auth_failed") {
|
|
370
|
+
// If we were using a stale device token, clear it and retry
|
|
371
|
+
// with the gateway token instead
|
|
372
|
+
if (savedDeviceToken) {
|
|
373
|
+
console.log("[Gateway] Device token rejected — clearing and retrying with gateway token");
|
|
374
|
+
clearDeviceToken();
|
|
375
|
+
this._skipDeviceAuth = false;
|
|
376
|
+
this.reconnectAttempts = 0;
|
|
377
|
+
this.scheduleReconnect();
|
|
378
|
+
} else {
|
|
379
|
+
// Real auth failure — gateway token is invalid
|
|
380
|
+
this._state = "error";
|
|
381
|
+
this.emit("stateChange", this._state);
|
|
382
|
+
this.emit("authError", msg.error);
|
|
383
|
+
}
|
|
384
|
+
} else if (errCode === "protocol_mismatch" || errCode === "protocol_unsupported") {
|
|
385
|
+
// Permanent failure — don't retry with the same protocol
|
|
386
|
+
console.error("[Gateway] Protocol version not supported by this Gateway");
|
|
195
387
|
this._state = "error";
|
|
196
388
|
this.emit("stateChange", this._state);
|
|
197
389
|
this.emit("authError", msg.error);
|
|
@@ -202,7 +394,7 @@ class GatewayConnection extends EventEmitter {
|
|
|
202
394
|
return;
|
|
203
395
|
}
|
|
204
396
|
|
|
205
|
-
// Forward any events
|
|
397
|
+
// Forward any other events during handshake
|
|
206
398
|
if (msg.type === "event") {
|
|
207
399
|
this.emit("gatewayEvent", {
|
|
208
400
|
event: msg.event,
|
|
@@ -211,12 +403,25 @@ class GatewayConnection extends EventEmitter {
|
|
|
211
403
|
} as GatewayEvent);
|
|
212
404
|
}
|
|
213
405
|
} catch (err) {
|
|
214
|
-
console.error("[Gateway] Failed to parse handshake message:", err);
|
|
406
|
+
console.error("[Gateway] Failed to parse handshake message:", (err as Error).message);
|
|
215
407
|
}
|
|
216
408
|
};
|
|
217
409
|
|
|
218
410
|
this.ws!.on("message", onHandshakeMessage);
|
|
219
|
-
this.ws!.send(JSON.stringify(
|
|
411
|
+
this.ws!.send(JSON.stringify(buildConnectFrame()));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Handle WebSocket upgrade failures (e.g. corporate proxies blocking WS)
|
|
415
|
+
this.ws.on("unexpected-response", (_req, res) => {
|
|
416
|
+
clearTimeout(connectTimer);
|
|
417
|
+
console.error(`[Gateway] WebSocket upgrade failed (HTTP ${res.statusCode})`);
|
|
418
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
419
|
+
console.error("[Gateway] Authentication rejected at HTTP level");
|
|
420
|
+
} else if (res.statusCode === 502 || res.statusCode === 503) {
|
|
421
|
+
console.error("[Gateway] Gateway may be behind a reverse proxy that doesn't support WebSocket");
|
|
422
|
+
}
|
|
423
|
+
this.cleanup();
|
|
424
|
+
this.scheduleReconnect();
|
|
220
425
|
});
|
|
221
426
|
|
|
222
427
|
this.ws.on("error", (err) => {
|
|
@@ -229,15 +434,32 @@ class GatewayConnection extends EventEmitter {
|
|
|
229
434
|
this.ws.on("close", (code, reason) => {
|
|
230
435
|
clearTimeout(connectTimer);
|
|
231
436
|
const wasConnected = this._state === "connected";
|
|
437
|
+
const reasonStr = reason?.toString() || "none";
|
|
232
438
|
this.cleanup();
|
|
439
|
+
|
|
440
|
+
// If Gateway rejected device auth (identity mismatch, bad signature, etc),
|
|
441
|
+
// fall back to token-only auth
|
|
442
|
+
if (code === 1008 && !this._skipDeviceAuth) {
|
|
443
|
+
console.log(`[Gateway] Device auth rejected (${reasonStr}) — retrying with token-only auth`);
|
|
444
|
+
this._skipDeviceAuth = true;
|
|
445
|
+
this.reconnectAttempts = 0;
|
|
446
|
+
this.scheduleReconnect();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
233
450
|
if (wasConnected) {
|
|
234
|
-
console.log(`[Gateway] Disconnected (code: ${code}, reason: ${
|
|
451
|
+
console.log(`[Gateway] Disconnected (code: ${code}, reason: ${reasonStr})`);
|
|
235
452
|
}
|
|
236
453
|
this.scheduleReconnect();
|
|
237
454
|
});
|
|
238
455
|
}
|
|
239
456
|
|
|
240
457
|
private onMessage(data: WebSocket.RawData): void {
|
|
458
|
+
const rawSize = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data.toString());
|
|
459
|
+
if (rawSize > MAX_MESSAGE_SIZE) {
|
|
460
|
+
console.error(`[Gateway] Message too large (${rawSize} bytes) — ignoring`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
241
463
|
let msg: GatewayFrame;
|
|
242
464
|
try {
|
|
243
465
|
msg = JSON.parse(data.toString());
|
|
@@ -308,14 +530,19 @@ class GatewayConnection extends EventEmitter {
|
|
|
308
530
|
// --------------------------------------------------------------------------
|
|
309
531
|
|
|
310
532
|
private resolveToken(): string | null {
|
|
311
|
-
// 1.
|
|
533
|
+
// 1. Saved device token from previous pairing
|
|
534
|
+
const deviceToken = getDeviceToken();
|
|
535
|
+
if (deviceToken) return deviceToken;
|
|
536
|
+
|
|
537
|
+
// 2. Castle config token
|
|
312
538
|
if (configExists()) {
|
|
313
539
|
const config = readConfig();
|
|
314
540
|
if (config.openclaw.gateway_token) {
|
|
315
541
|
return config.openclaw.gateway_token;
|
|
316
542
|
}
|
|
317
543
|
}
|
|
318
|
-
|
|
544
|
+
|
|
545
|
+
// 3. Auto-detect from OpenClaw config
|
|
319
546
|
return readOpenClawToken();
|
|
320
547
|
}
|
|
321
548
|
|
|
@@ -335,7 +562,7 @@ class GatewayConnection extends EventEmitter {
|
|
|
335
562
|
this.pending.delete(id);
|
|
336
563
|
}
|
|
337
564
|
|
|
338
|
-
if (this._state !== "error") {
|
|
565
|
+
if (this._state !== "error" && this._state !== "pairing") {
|
|
339
566
|
this._state = "disconnected";
|
|
340
567
|
this.emit("stateChange", this._state);
|
|
341
568
|
}
|
|
@@ -366,16 +593,26 @@ class GatewayConnection extends EventEmitter {
|
|
|
366
593
|
}
|
|
367
594
|
|
|
368
595
|
// ============================================================================
|
|
369
|
-
// Singleton export
|
|
596
|
+
// Singleton export (uses globalThis to survive HMR in dev mode)
|
|
370
597
|
// ============================================================================
|
|
371
598
|
|
|
372
|
-
|
|
599
|
+
const GATEWAY_KEY = "__castle_gateway__" as const;
|
|
600
|
+
|
|
601
|
+
function getGlobalGateway(): GatewayConnection | null {
|
|
602
|
+
return (globalThis as Record<string, unknown>)[GATEWAY_KEY] as GatewayConnection | null ?? null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function setGlobalGateway(gw: GatewayConnection): void {
|
|
606
|
+
(globalThis as Record<string, unknown>)[GATEWAY_KEY] = gw;
|
|
607
|
+
}
|
|
373
608
|
|
|
374
609
|
export function getGateway(): GatewayConnection {
|
|
375
|
-
|
|
376
|
-
|
|
610
|
+
let gw = getGlobalGateway();
|
|
611
|
+
if (!gw) {
|
|
612
|
+
gw = new GatewayConnection();
|
|
613
|
+
setGlobalGateway(gw);
|
|
377
614
|
}
|
|
378
|
-
return
|
|
615
|
+
return gw;
|
|
379
616
|
}
|
|
380
617
|
|
|
381
618
|
/**
|